From 62f06c40a4eac6d11f1a93f3b49dfe6c48ce16f8 Mon Sep 17 00:00:00 2001
From: Armand Philippot <git@armandphilippot.com>
Date: Mon, 11 Apr 2022 19:24:04 +0200
Subject: chore: add a Meta component

---
 src/components/molecules/layout/meta.tsx | 70 ++++++++++++++++++++++++++++++++
 1 file changed, 70 insertions(+)
 create mode 100644 src/components/molecules/layout/meta.tsx

(limited to 'src/components/molecules/layout/meta.tsx')

diff --git a/src/components/molecules/layout/meta.tsx b/src/components/molecules/layout/meta.tsx
new file mode 100644
index 0000000..ace6f89
--- /dev/null
+++ b/src/components/molecules/layout/meta.tsx
@@ -0,0 +1,70 @@
+import DescriptionList, {
+  type DescriptionListProps,
+  type DescriptionListItem,
+} from '@components/atoms/lists/description-list';
+import { ReactNode, VFC } from 'react';
+
+export type MetaItem = {
+  /**
+   * The meta name.
+   */
+  name: string;
+  /**
+   * The meta value.
+   */
+  value: ReactNode | ReactNode[];
+};
+
+export type MetaMap = {
+  [key: string]: MetaItem | undefined;
+};
+
+export type MetaProps = {
+  /**
+   * Set additional classnames to the meta wrapper.
+   */
+  className?: string;
+  /**
+   * The meta data.
+   */
+  data: MetaMap;
+  /**
+   * The meta layout.
+   */
+  layout?: DescriptionListProps['layout'];
+};
+
+/**
+ * Meta component
+ *
+ * Renders the page metadata.
+ */
+const Meta: VFC<MetaProps> = ({ data, ...props }) => {
+  /**
+   * Transform the metadata to description list item format.
+   *
+   * @param {MetaMap} items - The meta.
+   * @returns {DescriptionListItem[]} The formatted description list items.
+   */
+  const getItems = (items: MetaMap): DescriptionListItem[] => {
+    const listItems: DescriptionListItem[] = Object.entries(items)
+      .map(([key, item]) => {
+        if (!item) return;
+
+        const { name, value } = item;
+
+        return {
+          id: key,
+          term: name,
+          value: Array.isArray(value) ? value : [value],
+        } as DescriptionListItem;
+      })
+      .filter((item): item is DescriptionListItem => !!item);
+
+    return listItems;
+  };
+
+  return <DescriptionList items={getItems(data)} {...props} />;
+};
+
+export default Meta;
-- 
cgit v1.2.3


From edea15c845b33848b7b4f63616841e675b74d572 Mon Sep 17 00:00:00 2001
From: Armand Philippot <git@armandphilippot.com>
Date: Fri, 15 Apr 2022 22:20:09 +0200
Subject: chore: add an Overview component

---
 src/components/molecules/layout/meta.tsx           |  4 ++
 .../organisms/layout/overview.module.scss          | 12 ++++++
 .../organisms/layout/overview.stories.tsx          | 50 ++++++++++++++++++++++
 src/components/organisms/layout/overview.test.tsx  | 29 +++++++++++++
 src/components/organisms/layout/overview.tsx       | 33 ++++++++++++++
 5 files changed, 128 insertions(+)
 create mode 100644 src/components/organisms/layout/overview.module.scss
 create mode 100644 src/components/organisms/layout/overview.stories.tsx
 create mode 100644 src/components/organisms/layout/overview.test.tsx
 create mode 100644 src/components/organisms/layout/overview.tsx

(limited to 'src/components/molecules/layout/meta.tsx')

diff --git a/src/components/molecules/layout/meta.tsx b/src/components/molecules/layout/meta.tsx
index ace6f89..218ebd9 100644
--- a/src/components/molecules/layout/meta.tsx
+++ b/src/components/molecules/layout/meta.tsx
@@ -32,6 +32,10 @@ export type MetaProps = {
    * The meta layout.
    */
   layout?: DescriptionListProps['layout'];
+  /**
+   * Determine if the layout should be responsive.
+   */
+  responsiveLayout?: DescriptionListProps['responsiveLayout'];
 };
 
 /**
diff --git a/src/components/organisms/layout/overview.module.scss b/src/components/organisms/layout/overview.module.scss
new file mode 100644
index 0000000..4d50ad1
--- /dev/null
+++ b/src/components/organisms/layout/overview.module.scss
@@ -0,0 +1,12 @@
+@use "@styles/abstracts/functions" as fun;
+
+.wrapper {
+  padding: var(--spacing-sm) var(--spacing-md);
+  border: fun.convert-px(1) solid var(--color-border);
+}
+
+.cover {
+  max-height: fun.convert-px(150);
+  margin: 0 auto var(--spacing-md);
+  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
new file mode 100644
index 0000000..61d8b35
--- /dev/null
+++ b/src/components/organisms/layout/overview.stories.tsx
@@ -0,0 +1,50 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import OverviewComponent from './overview';
+
+export default {
+  title: 'Organisms/Layout',
+  component: OverviewComponent,
+  argTypes: {
+    cover: {
+      description: 'The overview cover.',
+      type: {
+        name: 'object',
+        required: true,
+        value: {},
+      },
+    },
+    meta: {
+      description: 'The overview metadata.',
+      type: {
+        name: 'object',
+        required: true,
+        value: {},
+      },
+    },
+  },
+} as ComponentMeta<typeof OverviewComponent>;
+
+const Template: ComponentStory<typeof OverviewComponent> = (args) => (
+  <OverviewComponent {...args} />
+);
+
+const cover = {
+  alt: 'picture',
+  height: 480,
+  src: 'http://placeimg.com/640/480/cats',
+  width: 640,
+};
+
+const meta = {
+  publication: { name: 'Illo ut odio:', value: 'Sequi et excepturi' },
+  update: {
+    name: 'Perspiciatis vel laudantium:',
+    value: 'Dignissimos ratione veritatis',
+  },
+};
+
+export const Overview = Template.bind({});
+Overview.args = {
+  cover,
+  meta,
+};
diff --git a/src/components/organisms/layout/overview.test.tsx b/src/components/organisms/layout/overview.test.tsx
new file mode 100644
index 0000000..0738d3f
--- /dev/null
+++ b/src/components/organisms/layout/overview.test.tsx
@@ -0,0 +1,29 @@
+import { render, screen } from '@test-utils';
+import Overview from './overview';
+
+const cover = {
+  alt: 'Incidunt unde quam',
+  height: 480,
+  src: 'http://placeimg.com/640/480/cats',
+  width: 640,
+};
+
+const meta = {
+  publication: { name: 'Illo ut odio:', value: 'Sequi et excepturi' },
+  update: {
+    name: 'Perspiciatis vel laudantium:',
+    value: 'Dignissimos ratione veritatis',
+  },
+};
+
+describe('Overview', () => {
+  it('renders some meta', () => {
+    render(<Overview meta={meta} />);
+    expect(screen.getByText(meta['publication'].name)).toBeInTheDocument();
+  });
+
+  it('renders a cover', () => {
+    render(<Overview meta={meta} cover={cover} />);
+    expect(screen.getByRole('img', { name: cover.alt })).toBeInTheDocument();
+  });
+});
diff --git a/src/components/organisms/layout/overview.tsx b/src/components/organisms/layout/overview.tsx
new file mode 100644
index 0000000..3f83342
--- /dev/null
+++ b/src/components/organisms/layout/overview.tsx
@@ -0,0 +1,33 @@
+import ResponsiveImage, {
+  type ResponsiveImageProps,
+} from '@components/molecules/images/responsive-image';
+import Meta, { type MetaMap } from '@components/molecules/layout/meta';
+import { VFC } from 'react';
+import styles from './overview.module.scss';
+
+export type OverviewProps = {
+  cover?: Pick<ResponsiveImageProps, 'alt' | 'src' | 'width' | 'height'>;
+  meta: MetaMap;
+};
+
+/**
+ * Overview component
+ *
+ * Render an overview.
+ */
+const Overview: VFC<OverviewProps> = ({ cover, meta }) => {
+  return (
+    <div className={styles.wrapper}>
+      {cover && (
+        <ResponsiveImage
+          objectFit="cover"
+          className={styles.cover}
+          {...cover}
+        />
+      )}
+      <Meta data={meta} layout="column" responsiveLayout={true} />
+    </div>
+  );
+};
+
+export default Overview;
-- 
cgit v1.2.3


From 5a6e4eea16047083e2de0e91a1b3ed9be8d6eb68 Mon Sep 17 00:00:00 2001
From: Armand Philippot <git@armandphilippot.com>
Date: Sat, 16 Apr 2022 16:08:49 +0200
Subject: refactor: support React 18

I replaced the deprecated VFC type with FC type and made all children
explicits.
Formatjs is still not compatible with React 18 so I need to skip type
checking when comitting. There are some type errors because of
IntlProvider in Storybook stories.
---
 jest.setup.js                                      |  1 +
 src/components/atoms/buttons/button-link.tsx       |  6 +-
 src/components/atoms/buttons/button.tsx            |  6 +-
 src/components/atoms/forms/checkbox.tsx            |  4 +-
 src/components/atoms/forms/field.tsx               |  4 +-
 src/components/atoms/forms/form.test.tsx           |  6 +-
 src/components/atoms/forms/form.tsx                |  6 +-
 src/components/atoms/forms/label.tsx               |  6 +-
 src/components/atoms/forms/select.tsx              |  4 +-
 src/components/atoms/headings/heading.tsx          |  6 +-
 src/components/atoms/icons/arrow.tsx               |  4 +-
 src/components/atoms/icons/career.tsx              |  4 +-
 src/components/atoms/icons/cc-by-sa.tsx            |  4 +-
 src/components/atoms/icons/close.tsx               |  4 +-
 src/components/atoms/icons/cog.tsx                 |  4 +-
 src/components/atoms/icons/computer-screen.tsx     |  4 +-
 src/components/atoms/icons/envelop.tsx             |  4 +-
 src/components/atoms/icons/hamburger.tsx           |  2 +-
 src/components/atoms/icons/home.tsx                |  4 +-
 src/components/atoms/icons/magnifying-glass.tsx    |  4 +-
 src/components/atoms/icons/moon.tsx                |  6 +-
 src/components/atoms/icons/plus-minus.tsx          |  2 +-
 src/components/atoms/icons/posts-stack.tsx         |  4 +-
 src/components/atoms/icons/sun.tsx                 |  6 +-
 src/components/atoms/images/logo.tsx               |  6 +-
 src/components/atoms/layout/copyright.tsx          |  4 +-
 src/components/atoms/layout/main.tsx               |  6 +-
 src/components/atoms/layout/no-script.tsx          |  4 +-
 src/components/atoms/layout/notice.tsx             |  4 +-
 src/components/atoms/layout/section.tsx            |  4 +-
 src/components/atoms/links/link.module.scss        | 20 ++++--
 src/components/atoms/links/link.tsx                |  6 +-
 src/components/atoms/links/nav-link.tsx            |  4 +-
 src/components/atoms/links/sharing-link.tsx        |  4 +-
 src/components/atoms/links/social-link.tsx         |  4 +-
 src/components/atoms/lists/description-list.tsx    |  4 +-
 src/components/atoms/lists/list.tsx                |  4 +-
 src/components/atoms/loaders/progress-bar.tsx      |  4 +-
 src/components/atoms/loaders/spinner.tsx           |  4 +-
 src/components/molecules/buttons/back-to-top.tsx   | 14 ++--
 .../molecules/buttons/heading-button.tsx           |  4 +-
 src/components/molecules/buttons/help-button.tsx   |  6 +-
 .../molecules/forms/ackee-select.test.tsx          | 16 +++--
 src/components/molecules/forms/ackee-select.tsx    |  6 +-
 src/components/molecules/forms/labelled-field.tsx  |  4 +-
 src/components/molecules/forms/labelled-select.tsx | 15 +++--
 src/components/molecules/forms/motion-toggle.tsx   |  8 +--
 .../molecules/forms/prism-theme-toggle.tsx         |  8 +--
 .../molecules/forms/select-with-tooltip.tsx        | 10 +--
 src/components/molecules/forms/theme-toggle.tsx    |  8 +--
 src/components/molecules/forms/toggle.tsx          | 18 ++---
 .../molecules/images/responsive-image.tsx          | 10 +--
 src/components/molecules/layout/branding.tsx       | 12 ++--
 src/components/molecules/layout/card.tsx           |  8 +--
 src/components/molecules/layout/flipping-logo.tsx  | 10 +--
 src/components/molecules/layout/meta.tsx           |  4 +-
 src/components/molecules/layout/widget.tsx         | 10 ++-
 src/components/molecules/modals/modal.test.tsx     | 13 +++-
 src/components/molecules/modals/modal.tsx          | 14 ++--
 src/components/molecules/modals/tooltip.tsx        |  4 +-
 src/components/molecules/nav/breadcrumb.tsx        |  4 +-
 src/components/molecules/nav/nav.tsx               |  4 +-
 .../organisms/forms/comment-form.stories.tsx       |  1 -
 src/components/organisms/forms/comment-form.tsx    |  4 +-
 src/components/organisms/forms/contact-form.tsx    |  4 +-
 src/components/organisms/forms/search-form.tsx     | 11 +++-
 src/components/organisms/layout/cards-list.tsx     |  4 +-
 src/components/organisms/layout/footer.tsx         | 13 ++--
 src/components/organisms/layout/overview.tsx       |  4 +-
 .../organisms/layout/summary.stories.tsx           |  2 +-
 src/components/organisms/layout/summary.test.tsx   |  2 +-
 src/components/organisms/layout/summary.tsx        | 77 +++++++++++++---------
 src/components/organisms/modals/search-modal.tsx   |  8 +--
 src/components/organisms/modals/settings-modal.tsx | 14 ++--
 src/components/organisms/toolbar/main-nav.tsx      | 18 +++--
 src/components/organisms/toolbar/search.tsx        | 16 ++---
 src/components/organisms/toolbar/settings.tsx      | 14 ++--
 src/components/organisms/toolbar/toolbar.tsx       |  4 +-
 src/components/organisms/widgets/image-widget.tsx  | 34 ++++------
 .../organisms/widgets/links-list-widget.tsx        |  9 ++-
 src/components/organisms/widgets/sharing.tsx       |  6 +-
 81 files changed, 353 insertions(+), 289 deletions(-)

(limited to 'src/components/molecules/layout/meta.tsx')

diff --git a/jest.setup.js b/jest.setup.js
index 5c5ecbc..d50c988 100644
--- a/jest.setup.js
+++ b/jest.setup.js
@@ -2,3 +2,4 @@ import '@testing-library/jest-dom/extend-expect';
 import './__tests__/jest/__mocks__/matchMedia.mock';
 
 jest.mock('next/dist/client/router', () => require('next-router-mock'));
+jest.mock('next/dynamic', () => () => 'dynamic-import');
diff --git a/src/components/atoms/buttons/button-link.tsx b/src/components/atoms/buttons/button-link.tsx
index 77a7f7b..906fa76 100644
--- a/src/components/atoms/buttons/button-link.tsx
+++ b/src/components/atoms/buttons/button-link.tsx
@@ -1,5 +1,5 @@
 import Link from 'next/link';
-import { FC } from 'react';
+import { FC, ReactNode } from 'react';
 import styles from './buttons.module.scss';
 
 export type ButtonLinkProps = {
@@ -7,6 +7,10 @@ export type ButtonLinkProps = {
    * ButtonLink accessible label.
    */
   'aria-label'?: string;
+  /**
+   * The button link body.
+   */
+  children: ReactNode;
   /**
    * Set additional classnames to the button link.
    */
diff --git a/src/components/atoms/buttons/button.tsx b/src/components/atoms/buttons/button.tsx
index 545c5c5..a6eef8b 100644
--- a/src/components/atoms/buttons/button.tsx
+++ b/src/components/atoms/buttons/button.tsx
@@ -1,7 +1,11 @@
-import { FC, MouseEventHandler } from 'react';
+import { FC, MouseEventHandler, ReactNode } from 'react';
 import styles from './buttons.module.scss';
 
 export type ButtonProps = {
+  /**
+   * The button body.
+   */
+  children: ReactNode;
   /**
    * Set additional classnames to the button wrapper.
    */
diff --git a/src/components/atoms/forms/checkbox.tsx b/src/components/atoms/forms/checkbox.tsx
index 8babcc8..aec97f0 100644
--- a/src/components/atoms/forms/checkbox.tsx
+++ b/src/components/atoms/forms/checkbox.tsx
@@ -1,4 +1,4 @@
-import { SetStateAction, VFC } from 'react';
+import { FC, SetStateAction } from 'react';
 
 export type CheckboxProps = {
   /**
@@ -32,7 +32,7 @@ export type CheckboxProps = {
  *
  * Render a checkbox type input.
  */
-const Checkbox: VFC<CheckboxProps> = ({ value, setValue, ...props }) => {
+const Checkbox: FC<CheckboxProps> = ({ value, setValue, ...props }) => {
   return (
     <input
       type="checkbox"
diff --git a/src/components/atoms/forms/field.tsx b/src/components/atoms/forms/field.tsx
index 2e75d0f..e45a8a7 100644
--- a/src/components/atoms/forms/field.tsx
+++ b/src/components/atoms/forms/field.tsx
@@ -1,4 +1,4 @@
-import { ChangeEvent, SetStateAction, VFC } from 'react';
+import { ChangeEvent, FC, SetStateAction } from 'react';
 import styles from './forms.module.scss';
 
 export type FieldType =
@@ -72,7 +72,7 @@ export type FieldProps = {
  *
  * Render either an input or a textarea.
  */
-const Field: VFC<FieldProps> = ({
+const Field: FC<FieldProps> = ({
   className = '',
   setValue,
   type,
diff --git a/src/components/atoms/forms/form.test.tsx b/src/components/atoms/forms/form.test.tsx
index 9cd3c58..8b534f1 100644
--- a/src/components/atoms/forms/form.test.tsx
+++ b/src/components/atoms/forms/form.test.tsx
@@ -3,7 +3,11 @@ import Form from './form';
 
 describe('Form', () => {
   it('renders a form', () => {
-    render(<Form aria-label="Jest form" onSubmit={() => null}></Form>);
+    render(
+      <Form aria-label="Jest form" onSubmit={() => null}>
+        Fields
+      </Form>
+    );
     expect(screen.getByRole('form', { name: 'Jest form' })).toBeInTheDocument();
   });
 });
diff --git a/src/components/atoms/forms/form.tsx b/src/components/atoms/forms/form.tsx
index 8e80930..ef8dce4 100644
--- a/src/components/atoms/forms/form.tsx
+++ b/src/components/atoms/forms/form.tsx
@@ -1,4 +1,4 @@
-import { Children, FC, FormEvent, Fragment } from 'react';
+import { Children, FC, FormEvent, Fragment, ReactNode } from 'react';
 import styles from './forms.module.scss';
 
 export type FormProps = {
@@ -10,6 +10,10 @@ export type FormProps = {
    * One or more ids that refers to the form name.
    */
   'aria-labelledby'?: string;
+  /**
+   * The form body.
+   */
+  children: ReactNode;
   /**
    * Set additional classnames to the form wrapper.
    */
diff --git a/src/components/atoms/forms/label.tsx b/src/components/atoms/forms/label.tsx
index 8d57ee2..ce4c70f 100644
--- a/src/components/atoms/forms/label.tsx
+++ b/src/components/atoms/forms/label.tsx
@@ -1,7 +1,11 @@
-import { FC } from 'react';
+import { FC, ReactNode } from 'react';
 import styles from './label.module.scss';
 
 export type LabelProps = {
+  /**
+   * The label body.
+   */
+  children: ReactNode;
   /**
    * Add classnames to the label.
    */
diff --git a/src/components/atoms/forms/select.tsx b/src/components/atoms/forms/select.tsx
index 25e86e0..dbe9b37 100644
--- a/src/components/atoms/forms/select.tsx
+++ b/src/components/atoms/forms/select.tsx
@@ -1,4 +1,4 @@
-import { ChangeEvent, SetStateAction, VFC } from 'react';
+import { ChangeEvent, FC, SetStateAction } from 'react';
 import styles from './forms.module.scss';
 
 export type SelectOptions = {
@@ -60,7 +60,7 @@ export type SelectProps = {
  *
  * Render a HTML select element.
  */
-const Select: VFC<SelectProps> = ({
+const Select: FC<SelectProps> = ({
   className = '',
   options,
   setValue,
diff --git a/src/components/atoms/headings/heading.tsx b/src/components/atoms/headings/heading.tsx
index 4703b5d..c5bf4ca 100644
--- a/src/components/atoms/headings/heading.tsx
+++ b/src/components/atoms/headings/heading.tsx
@@ -1,9 +1,13 @@
-import { FC } from 'react';
+import { FC, ReactNode } from 'react';
 import styles from './heading.module.scss';
 
 export type HeadingLevel = 1 | 2 | 3 | 4 | 5 | 6;
 
 export type HeadingProps = {
+  /**
+   * The heading body.
+   */
+  children: ReactNode;
   /**
    * Set additional classnames.
    */
diff --git a/src/components/atoms/icons/arrow.tsx b/src/components/atoms/icons/arrow.tsx
index 5f3c460..2962530 100644
--- a/src/components/atoms/icons/arrow.tsx
+++ b/src/components/atoms/icons/arrow.tsx
@@ -1,4 +1,4 @@
-import { VFC } from 'react';
+import { FC } from 'react';
 import styles from './arrow.module.scss';
 
 export type ArrowDirection = 'top' | 'right' | 'bottom' | 'left';
@@ -19,7 +19,7 @@ export type ArrowProps = {
  *
  * Render a svg arrow icon.
  */
-const Arrow: VFC<ArrowProps> = ({ className = '', direction }) => {
+const Arrow: FC<ArrowProps> = ({ className = '', direction }) => {
   const directionClass = styles[`icon--${direction}`];
   const classes = `${styles.icon} ${directionClass} ${className}`;
 
diff --git a/src/components/atoms/icons/career.tsx b/src/components/atoms/icons/career.tsx
index 28edcc7..f28d399 100644
--- a/src/components/atoms/icons/career.tsx
+++ b/src/components/atoms/icons/career.tsx
@@ -1,4 +1,4 @@
-import { VFC } from 'react';
+import { FC } from 'react';
 import styles from './career.module.scss';
 
 export type CareerProps = {
@@ -13,7 +13,7 @@ export type CareerProps = {
  *
  * Render a career svg icon.
  */
-const Career: VFC<CareerProps> = ({ className = '' }) => {
+const Career: FC<CareerProps> = ({ className = '' }) => {
   return (
     <svg
       viewBox="0 0 100 100"
diff --git a/src/components/atoms/icons/cc-by-sa.tsx b/src/components/atoms/icons/cc-by-sa.tsx
index 552504e..8239154 100644
--- a/src/components/atoms/icons/cc-by-sa.tsx
+++ b/src/components/atoms/icons/cc-by-sa.tsx
@@ -1,4 +1,4 @@
-import { VFC } from 'react';
+import { FC } from 'react';
 import { useIntl } from 'react-intl';
 import styles from './cc-by-sa.module.scss';
 
@@ -14,7 +14,7 @@ export type CCBySAProps = {
  *
  * Render a CC BY SA svg icon.
  */
-const CCBySA: VFC<CCBySAProps> = ({ className = '' }) => {
+const CCBySA: FC<CCBySAProps> = ({ className = '' }) => {
   const intl = useIntl();
 
   return (
diff --git a/src/components/atoms/icons/close.tsx b/src/components/atoms/icons/close.tsx
index eb9ce7c..3e0adb5 100644
--- a/src/components/atoms/icons/close.tsx
+++ b/src/components/atoms/icons/close.tsx
@@ -1,4 +1,4 @@
-import { VFC } from 'react';
+import { FC } from 'react';
 import styles from './close.module.scss';
 
 export type CloseProps = {
@@ -13,7 +13,7 @@ export type CloseProps = {
  *
  * Render a close svg icon.
  */
-const Close: VFC<CloseProps> = ({ className = '' }) => {
+const Close: FC<CloseProps> = ({ className = '' }) => {
   return (
     <svg
       viewBox="0 0 100 100"
diff --git a/src/components/atoms/icons/cog.tsx b/src/components/atoms/icons/cog.tsx
index df6d54d..9e78a7b 100644
--- a/src/components/atoms/icons/cog.tsx
+++ b/src/components/atoms/icons/cog.tsx
@@ -1,4 +1,4 @@
-import { VFC } from 'react';
+import { FC } from 'react';
 import styles from './cog.module.scss';
 
 export type CogProps = {
@@ -13,7 +13,7 @@ export type CogProps = {
  *
  * Render a cog svg icon.
  */
-const Cog: VFC<CogProps> = ({ className = '' }) => {
+const Cog: FC<CogProps> = ({ className = '' }) => {
   return (
     <svg
       viewBox="0 0 100 100"
diff --git a/src/components/atoms/icons/computer-screen.tsx b/src/components/atoms/icons/computer-screen.tsx
index 310836f..8786139 100644
--- a/src/components/atoms/icons/computer-screen.tsx
+++ b/src/components/atoms/icons/computer-screen.tsx
@@ -1,4 +1,4 @@
-import { VFC } from 'react';
+import { FC } from 'react';
 import styles from './computer-screen.module.scss';
 
 export type ComputerScreenProps = {
@@ -13,7 +13,7 @@ export type ComputerScreenProps = {
  *
  * Render a computer screen svg icon.
  */
-const ComputerScreen: VFC<ComputerScreenProps> = ({ className = '' }) => {
+const ComputerScreen: FC<ComputerScreenProps> = ({ className = '' }) => {
   return (
     <svg
       viewBox="0 0 100 100"
diff --git a/src/components/atoms/icons/envelop.tsx b/src/components/atoms/icons/envelop.tsx
index 7b50d1d..84dca97 100644
--- a/src/components/atoms/icons/envelop.tsx
+++ b/src/components/atoms/icons/envelop.tsx
@@ -1,4 +1,4 @@
-import { VFC } from 'react';
+import { FC } from 'react';
 import styles from './envelop.module.scss';
 
 export type EnvelopProps = {
@@ -13,7 +13,7 @@ export type EnvelopProps = {
  *
  * Render an envelop svg icon.
  */
-const Envelop: VFC<EnvelopProps> = ({ className = '' }) => {
+const Envelop: FC<EnvelopProps> = ({ className = '' }) => {
   return (
     <svg
       viewBox="0 0 100 100"
diff --git a/src/components/atoms/icons/hamburger.tsx b/src/components/atoms/icons/hamburger.tsx
index 7e7c2c9..93aed2a 100644
--- a/src/components/atoms/icons/hamburger.tsx
+++ b/src/components/atoms/icons/hamburger.tsx
@@ -1,7 +1,7 @@
 import { FC } from 'react';
 import styles from './hamburger.module.scss';
 
-type HamburgerProps = {
+export type HamburgerProps = {
   /**
    * Set additional classnames to the icon wrapper.
    */
diff --git a/src/components/atoms/icons/home.tsx b/src/components/atoms/icons/home.tsx
index 71bbc4a..3b6732d 100644
--- a/src/components/atoms/icons/home.tsx
+++ b/src/components/atoms/icons/home.tsx
@@ -1,4 +1,4 @@
-import { VFC } from 'react';
+import { FC } from 'react';
 import styles from './home.module.scss';
 
 export type HomeProps = {
@@ -13,7 +13,7 @@ export type HomeProps = {
  *
  * Render a home svg icon.
  */
-const Home: VFC<HomeProps> = ({ className = '' }) => {
+const Home: FC<HomeProps> = ({ className = '' }) => {
   return (
     <svg
       viewBox="0 0 100 100"
diff --git a/src/components/atoms/icons/magnifying-glass.tsx b/src/components/atoms/icons/magnifying-glass.tsx
index 445ef10..1ca2a44 100644
--- a/src/components/atoms/icons/magnifying-glass.tsx
+++ b/src/components/atoms/icons/magnifying-glass.tsx
@@ -1,4 +1,4 @@
-import { VFC } from 'react';
+import { FC } from 'react';
 import styles from './magnifying-glass.module.scss';
 
 export type MagnifyingGlassProps = {
@@ -13,7 +13,7 @@ export type MagnifyingGlassProps = {
  *
  * Render a magnifying glass svg icon.
  */
-const MagnifyingGlass: VFC<MagnifyingGlassProps> = ({ className = '' }) => {
+const MagnifyingGlass: FC<MagnifyingGlassProps> = ({ className = '' }) => {
   return (
     <svg
       viewBox="0 0 100 100"
diff --git a/src/components/atoms/icons/moon.tsx b/src/components/atoms/icons/moon.tsx
index 4f52319..ec4fa0c 100644
--- a/src/components/atoms/icons/moon.tsx
+++ b/src/components/atoms/icons/moon.tsx
@@ -1,7 +1,7 @@
-import { VFC } from 'react';
+import { FC } from 'react';
 import styles from './moon.module.scss';
 
-type MoonProps = {
+export type MoonProps = {
   /**
    * Set additional classnames to the icon.
    */
@@ -12,7 +12,7 @@ type MoonProps = {
   title?: string;
 };
 
-const Moon: VFC<MoonProps> = ({ className = '', title }) => {
+const Moon: FC<MoonProps> = ({ className = '', title }) => {
   return (
     <svg
       className={`${styles.icon} ${className}`}
diff --git a/src/components/atoms/icons/plus-minus.tsx b/src/components/atoms/icons/plus-minus.tsx
index 78aa14a..e8897b7 100644
--- a/src/components/atoms/icons/plus-minus.tsx
+++ b/src/components/atoms/icons/plus-minus.tsx
@@ -1,7 +1,7 @@
 import { FC } from 'react';
 import styles from './plus-minus.module.scss';
 
-type PlusMinusProps = {
+export type PlusMinusProps = {
   /**
    * Set additional classnames to the icon.
    */
diff --git a/src/components/atoms/icons/posts-stack.tsx b/src/components/atoms/icons/posts-stack.tsx
index 1998d25..ab21323 100644
--- a/src/components/atoms/icons/posts-stack.tsx
+++ b/src/components/atoms/icons/posts-stack.tsx
@@ -1,4 +1,4 @@
-import { VFC } from 'react';
+import { FC } from 'react';
 import styles from './posts-stack.module.scss';
 
 export type PostsStackProps = {
@@ -13,7 +13,7 @@ export type PostsStackProps = {
  *
  * Render a posts stack svg icon.
  */
-const PostsStack: VFC<PostsStackProps> = ({ className = '' }) => {
+const PostsStack: FC<PostsStackProps> = ({ className = '' }) => {
   return (
     <svg
       viewBox="0 0 100 100"
diff --git a/src/components/atoms/icons/sun.tsx b/src/components/atoms/icons/sun.tsx
index fa9d922..ca31747 100644
--- a/src/components/atoms/icons/sun.tsx
+++ b/src/components/atoms/icons/sun.tsx
@@ -1,7 +1,7 @@
-import { VFC } from 'react';
+import { FC } from 'react';
 import styles from './sun.module.scss';
 
-type SunProps = {
+export type SunProps = {
   /**
    * Set additional classnames to the icon.
    */
@@ -17,7 +17,7 @@ type SunProps = {
  *
  * Render a svg sun icon.
  */
-const Sun: VFC<SunProps> = ({ className = '', title }) => {
+const Sun: FC<SunProps> = ({ className = '', title }) => {
   return (
     <svg
       className={`${styles.sun} ${className}`}
diff --git a/src/components/atoms/images/logo.tsx b/src/components/atoms/images/logo.tsx
index 2e52110..3978882 100644
--- a/src/components/atoms/images/logo.tsx
+++ b/src/components/atoms/images/logo.tsx
@@ -1,7 +1,7 @@
-import { VFC } from 'react';
+import { FC } from 'react';
 import styles from './logo.module.scss';
 
-type LogoProps = {
+export type LogoProps = {
   /**
    * SVG Image title.
    */
@@ -13,7 +13,7 @@ type LogoProps = {
  *
  * Render a SVG logo.
  */
-const Logo: VFC<LogoProps> = ({ title }) => {
+const Logo: FC<LogoProps> = ({ title }) => {
   return (
     <svg
       viewBox="0 0 512 512"
diff --git a/src/components/atoms/layout/copyright.tsx b/src/components/atoms/layout/copyright.tsx
index 76252ee..f70695d 100644
--- a/src/components/atoms/layout/copyright.tsx
+++ b/src/components/atoms/layout/copyright.tsx
@@ -1,4 +1,4 @@
-import { ReactNode, VFC } from 'react';
+import { FC, ReactNode } from 'react';
 import styles from './copyright.module.scss';
 
 export type CopyrightDates = {
@@ -32,7 +32,7 @@ export type CopyrightProps = {
  *
  * Renders a copyright information (owner, dates, license icon).
  */
-const Copyright: VFC<CopyrightProps> = ({ owner, dates, icon }) => {
+const Copyright: FC<CopyrightProps> = ({ owner, dates, icon }) => {
   const getFormattedDate = (date: string) => {
     const datetime = new Date(date).toISOString();
 
diff --git a/src/components/atoms/layout/main.tsx b/src/components/atoms/layout/main.tsx
index 4549328..d92a5c7 100644
--- a/src/components/atoms/layout/main.tsx
+++ b/src/components/atoms/layout/main.tsx
@@ -1,6 +1,10 @@
-import { FC } from 'react';
+import { FC, ReactNode } from 'react';
 
 export type MainProps = {
+  /**
+   * The main body.
+   */
+  children: ReactNode;
   /**
    * Set additional classnames to the main element.
    */
diff --git a/src/components/atoms/layout/no-script.tsx b/src/components/atoms/layout/no-script.tsx
index 6358cf8..a503e0c 100644
--- a/src/components/atoms/layout/no-script.tsx
+++ b/src/components/atoms/layout/no-script.tsx
@@ -1,4 +1,4 @@
-import { VFC } from 'react';
+import { FC } from 'react';
 import styles from './no-script.module.scss';
 
 export type NoScriptProps = {
@@ -12,7 +12,7 @@ export type NoScriptProps = {
   position?: 'initial' | 'top';
 };
 
-const NoScript: VFC<NoScriptProps> = ({ message, position = 'initial' }) => {
+const NoScript: FC<NoScriptProps> = ({ message, position = 'initial' }) => {
   const positionClass = styles[`noscript--${position}`];
 
   return <div className={`${styles.noscript} ${positionClass}`}>{message}</div>;
diff --git a/src/components/atoms/layout/notice.tsx b/src/components/atoms/layout/notice.tsx
index b6e09c5..115bd9c 100644
--- a/src/components/atoms/layout/notice.tsx
+++ b/src/components/atoms/layout/notice.tsx
@@ -1,4 +1,4 @@
-import { VFC } from 'react';
+import { FC } from 'react';
 import styles from './notice.module.scss';
 
 export type NoticeKind = 'error' | 'info' | 'success' | 'warning';
@@ -19,7 +19,7 @@ export type NoticeProps = {
  *
  * Render a colored message depending on notice kind.
  */
-const Notice: VFC<NoticeProps> = ({ kind, message }) => {
+const Notice: FC<NoticeProps> = ({ kind, message }) => {
   const kindClass = `wrapper--${kind}`;
 
   return (
diff --git a/src/components/atoms/layout/section.tsx b/src/components/atoms/layout/section.tsx
index f1bbb34..cb727ff 100644
--- a/src/components/atoms/layout/section.tsx
+++ b/src/components/atoms/layout/section.tsx
@@ -1,4 +1,4 @@
-import { ReactNode, VFC } from 'react';
+import { FC, ReactNode } from 'react';
 import Heading from '../headings/heading';
 import styles from './section.module.scss';
 
@@ -32,7 +32,7 @@ export type SectionProps = {
  *
  * Render a section element.
  */
-const Section: VFC<SectionProps> = ({
+const Section: FC<SectionProps> = ({
   className = '',
   content,
   title,
diff --git a/src/components/atoms/links/link.module.scss b/src/components/atoms/links/link.module.scss
index e7ead86..d23667a 100644
--- a/src/components/atoms/links/link.module.scss
+++ b/src/components/atoms/links/link.module.scss
@@ -5,7 +5,9 @@
   &[hreflang] {
     &::after {
       display: inline-block;
-      content: "\0000a0["attr(hreflang) "]";
+      /* Prettier is removing spacing between content parts. */
+      /* prettier-ignore */
+      content: "\0000a0[" attr(hreflang) "]";
       font-size: var(--font-size-sm);
     }
   }
@@ -13,22 +15,30 @@
   &--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>'));
+      /* 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 {
-      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>'));
+      /* 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 {
-        content: "\0000a0["attr(hreflang) "]\0000a0"url(fun.encode-svg(
+        /* 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 {
-        content: "\0000a0["attr(hreflang) "]\0000a0"url(fun.encode-svg(
+        /* 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>'
           ));
       }
diff --git a/src/components/atoms/links/link.tsx b/src/components/atoms/links/link.tsx
index 87f11fc..674c07b 100644
--- a/src/components/atoms/links/link.tsx
+++ b/src/components/atoms/links/link.tsx
@@ -1,8 +1,12 @@
 import NextLink from 'next/link';
-import { FC } from 'react';
+import { FC, ReactNode } from 'react';
 import styles from './link.module.scss';
 
 export type LinkProps = {
+  /**
+   * The link body.
+   */
+  children: ReactNode;
   /**
    * Set additional classnames to the link.
    */
diff --git a/src/components/atoms/links/nav-link.tsx b/src/components/atoms/links/nav-link.tsx
index 25c0e7d..7c6fede 100644
--- a/src/components/atoms/links/nav-link.tsx
+++ b/src/components/atoms/links/nav-link.tsx
@@ -1,5 +1,5 @@
 import Link from 'next/link';
-import { VFC, ReactNode } from 'react';
+import { FC, ReactNode } from 'react';
 import styles from './nav-link.module.scss';
 
 export type NavLinkProps = {
@@ -22,7 +22,7 @@ export type NavLinkProps = {
  *
  * Render a navigation link.
  */
-const NavLink: VFC<NavLinkProps> = ({ href, label, logo }) => {
+const NavLink: FC<NavLinkProps> = ({ href, label, logo }) => {
   return (
     <Link href={href}>
       <a className={styles.link}>
diff --git a/src/components/atoms/links/sharing-link.tsx b/src/components/atoms/links/sharing-link.tsx
index 3cd2dd1..ca53ef9 100644
--- a/src/components/atoms/links/sharing-link.tsx
+++ b/src/components/atoms/links/sharing-link.tsx
@@ -1,4 +1,4 @@
-import { VFC } from 'react';
+import { FC } from 'react';
 import { useIntl } from 'react-intl';
 import styles from './sharing-link.module.scss';
 
@@ -26,7 +26,7 @@ export type SharingLinkProps = {
  *
  * Render a sharing link.
  */
-const SharingLink: VFC<SharingLinkProps> = ({ medium, url }) => {
+const SharingLink: FC<SharingLinkProps> = ({ medium, url }) => {
   const intl = useIntl();
   const text = intl.formatMessage(
     {
diff --git a/src/components/atoms/links/social-link.tsx b/src/components/atoms/links/social-link.tsx
index 8c7c790..464bc60 100644
--- a/src/components/atoms/links/social-link.tsx
+++ b/src/components/atoms/links/social-link.tsx
@@ -2,7 +2,7 @@ import GithubIcon from '@assets/images/social-media/github.svg';
 import GitlabIcon from '@assets/images/social-media/gitlab.svg';
 import LinkedInIcon from '@assets/images/social-media/linkedin.svg';
 import TwitterIcon from '@assets/images/social-media/twitter.svg';
-import { VFC } from 'react';
+import { FC } from 'react';
 import styles from './social-link.module.scss';
 
 export type SocialWebsite = 'Github' | 'Gitlab' | 'LinkedIn' | 'Twitter';
@@ -23,7 +23,7 @@ export type SocialLinkProps = {
  *
  * Render a social icon link.
  */
-const SocialLink: VFC<SocialLinkProps> = ({ name, url }) => {
+const SocialLink: FC<SocialLinkProps> = ({ name, url }) => {
   /**
    * Retrieve a social link icon by id.
    * @param {string} id - The social website id.
diff --git a/src/components/atoms/lists/description-list.tsx b/src/components/atoms/lists/description-list.tsx
index 0a92465..a60a6a1 100644
--- a/src/components/atoms/lists/description-list.tsx
+++ b/src/components/atoms/lists/description-list.tsx
@@ -1,4 +1,4 @@
-import { VFC } from 'react';
+import { FC } from 'react';
 import styles from './description-list.module.scss';
 
 export type DescriptionListItem = {
@@ -52,7 +52,7 @@ export type DescriptionListProps = {
  *
  * Render a description list.
  */
-const DescriptionList: VFC<DescriptionListProps> = ({
+const DescriptionList: FC<DescriptionListProps> = ({
   className = '',
   descriptionClassName = '',
   groupClassName = '',
diff --git a/src/components/atoms/lists/list.tsx b/src/components/atoms/lists/list.tsx
index d100a31..6726802 100644
--- a/src/components/atoms/lists/list.tsx
+++ b/src/components/atoms/lists/list.tsx
@@ -1,4 +1,4 @@
-import { VFC } from 'react';
+import { FC } from 'react';
 import styles from './list.module.scss';
 
 export type ListItem = {
@@ -44,7 +44,7 @@ export type ListProps = {
  *
  * Render either an ordered or an unordered list.
  */
-const List: VFC<ListProps> = ({
+const List: FC<ListProps> = ({
   className = '',
   items,
   itemsClassName = '',
diff --git a/src/components/atoms/loaders/progress-bar.tsx b/src/components/atoms/loaders/progress-bar.tsx
index 1b1ff06..9bac847 100644
--- a/src/components/atoms/loaders/progress-bar.tsx
+++ b/src/components/atoms/loaders/progress-bar.tsx
@@ -1,4 +1,4 @@
-import { VFC } from 'react';
+import { FC } from 'react';
 import styles from './progress-bar.module.scss';
 
 export type ProgressBarProps = {
@@ -29,7 +29,7 @@ export type ProgressBarProps = {
  *
  * Render a progress bar.
  */
-const ProgressBar: VFC<ProgressBarProps> = ({
+const ProgressBar: FC<ProgressBarProps> = ({
   current,
   info,
   min,
diff --git a/src/components/atoms/loaders/spinner.tsx b/src/components/atoms/loaders/spinner.tsx
index bff0f25..6655141 100644
--- a/src/components/atoms/loaders/spinner.tsx
+++ b/src/components/atoms/loaders/spinner.tsx
@@ -1,4 +1,4 @@
-import { VFC } from 'react';
+import { FC } from 'react';
 import { useIntl } from 'react-intl';
 import styles from './spinner.module.scss';
 
@@ -14,7 +14,7 @@ export type SpinnerProps = {
  *
  * Render a loading message with animation.
  */
-const Spinner: VFC<SpinnerProps> = ({ message }) => {
+const Spinner: FC<SpinnerProps> = ({ message }) => {
   const intl = useIntl();
 
   return (
diff --git a/src/components/molecules/buttons/back-to-top.tsx b/src/components/molecules/buttons/back-to-top.tsx
index 8a52231..bd1925a 100644
--- a/src/components/molecules/buttons/back-to-top.tsx
+++ b/src/components/molecules/buttons/back-to-top.tsx
@@ -1,18 +1,16 @@
-import ButtonLink from '@components/atoms/buttons/button-link';
+import ButtonLink, {
+  type ButtonLinkProps,
+} from '@components/atoms/buttons/button-link';
 import Arrow from '@components/atoms/icons/arrow';
-import { VFC } from 'react';
+import { FC } from 'react';
 import { useIntl } from 'react-intl';
 import styles from './back-to-top.module.scss';
 
-export type BackToTopProps = {
+export type BackToTopProps = Pick<ButtonLinkProps, 'target'> & {
   /**
    * Set additional classnames to the button wrapper.
    */
   className?: string;
-  /**
-   * An element id (without hashtag) to use as anchor.
-   */
-  target: string;
 };
 
 /**
@@ -20,7 +18,7 @@ export type BackToTopProps = {
  *
  * Render a back to top link.
  */
-const BackToTop: VFC<BackToTopProps> = ({ className = '', target }) => {
+const BackToTop: FC<BackToTopProps> = ({ className = '', target }) => {
   const intl = useIntl();
   const linkName = intl.formatMessage({
     defaultMessage: 'Back to top',
diff --git a/src/components/molecules/buttons/heading-button.tsx b/src/components/molecules/buttons/heading-button.tsx
index fc79749..0ed9a76 100644
--- a/src/components/molecules/buttons/heading-button.tsx
+++ b/src/components/molecules/buttons/heading-button.tsx
@@ -1,6 +1,6 @@
 import Heading, { type HeadingProps } from '@components/atoms/headings/heading';
 import PlusMinus from '@components/atoms/icons/plus-minus';
-import { SetStateAction, VFC } from 'react';
+import { FC, SetStateAction } from 'react';
 import { useIntl } from 'react-intl';
 import styles from './heading-button.module.scss';
 
@@ -28,7 +28,7 @@ export type HeadingButtonProps = Pick<HeadingProps, 'level'> & {
  *
  * Render a button as accordion title to toggle body.
  */
-const HeadingButton: VFC<HeadingButtonProps> = ({
+const HeadingButton: FC<HeadingButtonProps> = ({
   className = '',
   expanded,
   level,
diff --git a/src/components/molecules/buttons/help-button.tsx b/src/components/molecules/buttons/help-button.tsx
index aeb84ec..f19322f 100644
--- a/src/components/molecules/buttons/help-button.tsx
+++ b/src/components/molecules/buttons/help-button.tsx
@@ -1,5 +1,5 @@
-import Button, { ButtonProps } from '@components/atoms/buttons/button';
-import { VFC } from 'react';
+import Button, { type ButtonProps } from '@components/atoms/buttons/button';
+import { FC } from 'react';
 import { useIntl } from 'react-intl';
 import styles from './help-button.module.scss';
 
@@ -15,7 +15,7 @@ export type HelpButtonProps = Pick<ButtonProps, 'onClick'> & {
  *
  * Render a button with an interrogation mark icon.
  */
-const HelpButton: VFC<HelpButtonProps> = ({ className = '', onClick }) => {
+const HelpButton: FC<HelpButtonProps> = ({ className = '', onClick }) => {
   const intl = useIntl();
   const text = intl.formatMessage({
     defaultMessage: 'Help',
diff --git a/src/components/molecules/forms/ackee-select.test.tsx b/src/components/molecules/forms/ackee-select.test.tsx
index e1e6b2d..ec27922 100644
--- a/src/components/molecules/forms/ackee-select.test.tsx
+++ b/src/components/molecules/forms/ackee-select.test.tsx
@@ -1,5 +1,5 @@
-import userEvent from '@testing-library/user-event';
-import { render, screen } from '@test-utils';
+import user from '@testing-library/user-event';
+import { act, render, screen } from '@test-utils';
 import AckeeSelect from './ackee-select';
 
 describe('Select', () => {
@@ -9,13 +9,15 @@ describe('Select', () => {
     expect(screen.queryByRole('combobox')).not.toHaveValue('partial');
   });
 
-  it('should correctly change value when user choose another option', () => {
+  it('should correctly change value when user choose another option', async () => {
     render(<AckeeSelect initialValue="full" />);
 
-    userEvent.selectOptions(
-      screen.getByRole('combobox'),
-      screen.getByRole('option', { name: 'Partial' })
-    );
+    await act(async () => {
+      await user.selectOptions(
+        screen.getByRole('combobox'),
+        screen.getByRole('option', { name: 'Partial' })
+      );
+    });
 
     expect(screen.getByRole('combobox')).toHaveValue('partial');
     expect(screen.queryByRole('combobox')).not.toHaveValue('full');
diff --git a/src/components/molecules/forms/ackee-select.tsx b/src/components/molecules/forms/ackee-select.tsx
index 4a8410c..101e5b5 100644
--- a/src/components/molecules/forms/ackee-select.tsx
+++ b/src/components/molecules/forms/ackee-select.tsx
@@ -1,8 +1,8 @@
 import { SelectOptions } from '@components/atoms/forms/select';
-import { Dispatch, SetStateAction, useState, VFC } from 'react';
+import { Dispatch, FC, SetStateAction, useState } from 'react';
 import { useIntl } from 'react-intl';
 import SelectWithTooltip, {
-  SelectWithTooltipProps,
+  type SelectWithTooltipProps,
 } from './select-with-tooltip';
 
 export type AckeeOptions = 'full' | 'partial';
@@ -22,7 +22,7 @@ export type AckeeSelectProps = Pick<
  *
  * Render a select to set Ackee settings.
  */
-const AckeeSelect: VFC<AckeeSelectProps> = ({ initialValue, ...props }) => {
+const AckeeSelect: FC<AckeeSelectProps> = ({ initialValue, ...props }) => {
   const intl = useIntl();
   const [value, setValue] = useState<AckeeOptions>(initialValue);
 
diff --git a/src/components/molecules/forms/labelled-field.tsx b/src/components/molecules/forms/labelled-field.tsx
index 08d0126..ecc9255 100644
--- a/src/components/molecules/forms/labelled-field.tsx
+++ b/src/components/molecules/forms/labelled-field.tsx
@@ -1,6 +1,6 @@
 import Field, { type FieldProps } from '@components/atoms/forms/field';
 import Label from '@components/atoms/forms/label';
-import { VFC } from 'react';
+import { FC } from 'react';
 import styles from './labelled-field.module.scss';
 
 export type LabelledFieldProps = FieldProps & {
@@ -23,7 +23,7 @@ export type LabelledFieldProps = FieldProps & {
  *
  * Render a field tied to a label.
  */
-const LabelledField: VFC<LabelledFieldProps> = ({
+const LabelledField: FC<LabelledFieldProps> = ({
   hideLabel = false,
   id,
   label,
diff --git a/src/components/molecules/forms/labelled-select.tsx b/src/components/molecules/forms/labelled-select.tsx
index 7d4237a..23057d0 100644
--- a/src/components/molecules/forms/labelled-select.tsx
+++ b/src/components/molecules/forms/labelled-select.tsx
@@ -1,6 +1,6 @@
-import Label, { LabelProps } from '@components/atoms/forms/label';
+import Label, { type LabelProps } from '@components/atoms/forms/label';
 import Select, { type SelectProps } from '@components/atoms/forms/select';
-import { VFC } from 'react';
+import { FC } from 'react';
 import styles from './labelled-select.module.scss';
 
 export type LabelledSelectProps = Omit<
@@ -14,7 +14,7 @@ export type LabelledSelectProps = Omit<
   /**
    * Set additional classnames to the label.
    */
-  labelClassName?: string;
+  labelClassName?: LabelProps['className'];
   /**
    * The label position. Default: top.
    */
@@ -26,10 +26,15 @@ export type LabelledSelectProps = Omit<
   /**
    * Set additional classnames to the select field.
    */
-  selectClassName?: string;
+  selectClassName?: SelectProps['className'];
 };
 
-const LabelledSelect: VFC<LabelledSelectProps> = ({
+/**
+ * LabelledSelect component
+ *
+ * Render a select with a label.
+ */
+const LabelledSelect: FC<LabelledSelectProps> = ({
   id,
   label,
   labelClassName = '',
diff --git a/src/components/molecules/forms/motion-toggle.tsx b/src/components/molecules/forms/motion-toggle.tsx
index 9f30b42..24b54ae 100644
--- a/src/components/molecules/forms/motion-toggle.tsx
+++ b/src/components/molecules/forms/motion-toggle.tsx
@@ -1,8 +1,8 @@
 import Toggle, {
-  ToggleChoices,
-  ToggleProps,
+  type ToggleChoices,
+  type ToggleProps,
 } from '@components/molecules/forms/toggle';
-import { useState, VFC } from 'react';
+import { FC, useState } from 'react';
 import { useIntl } from 'react-intl';
 
 export type MotionToggleProps = Pick<ToggleProps, 'labelClassName' | 'value'>;
@@ -12,7 +12,7 @@ export type MotionToggleProps = Pick<ToggleProps, 'labelClassName' | 'value'>;
  *
  * Render a Toggle component to set reduce motion.
  */
-const MotionToggle: VFC<MotionToggleProps> = ({ value, ...props }) => {
+const MotionToggle: FC<MotionToggleProps> = ({ value, ...props }) => {
   const intl = useIntl();
   const [isDeactivated, setIsDeactivated] = useState<boolean>(value);
   const reduceMotionLabel = intl.formatMessage({
diff --git a/src/components/molecules/forms/prism-theme-toggle.tsx b/src/components/molecules/forms/prism-theme-toggle.tsx
index daee6bd..0b9c447 100644
--- a/src/components/molecules/forms/prism-theme-toggle.tsx
+++ b/src/components/molecules/forms/prism-theme-toggle.tsx
@@ -1,10 +1,10 @@
 import Moon from '@components/atoms/icons/moon';
 import Sun from '@components/atoms/icons/sun';
 import Toggle, {
-  ToggleChoices,
-  ToggleProps,
+  type ToggleChoices,
+  type ToggleProps,
 } from '@components/molecules/forms/toggle';
-import { useState, VFC } from 'react';
+import { FC, useState } from 'react';
 import { useIntl } from 'react-intl';
 
 export type PrismThemeToggleProps = Pick<
@@ -17,7 +17,7 @@ export type PrismThemeToggleProps = Pick<
  *
  * Render a Toggle component to set code blocks theme.
  */
-const PrismThemeToggle: VFC<PrismThemeToggleProps> = ({ value, ...props }) => {
+const PrismThemeToggle: FC<PrismThemeToggleProps> = ({ value, ...props }) => {
   const intl = useIntl();
   const [isDarkTheme, setIsDarkTheme] = useState<boolean>(value);
   const themeLabel = intl.formatMessage({
diff --git a/src/components/molecules/forms/select-with-tooltip.tsx b/src/components/molecules/forms/select-with-tooltip.tsx
index f537e1e..cf7b041 100644
--- a/src/components/molecules/forms/select-with-tooltip.tsx
+++ b/src/components/molecules/forms/select-with-tooltip.tsx
@@ -1,4 +1,4 @@
-import { useState, VFC } from 'react';
+import { FC, useState } from 'react';
 import HelpButton from '../buttons/help-button';
 import Tooltip, { type TooltipProps } from '../modals/tooltip';
 import LabelledSelect, { type LabelledSelectProps } from './labelled-select';
@@ -9,14 +9,10 @@ export type SelectWithTooltipProps = Omit<
   'labelPosition'
 > &
   Pick<TooltipProps, 'title' | 'content'> & {
-    /**
-     * The select label.
-     */
-    label: string;
     /**
      * Set additional classnames to the tooltip wrapper.
      */
-    tooltipClassName?: string;
+    tooltipClassName?: TooltipProps['className'];
   };
 
 /**
@@ -24,7 +20,7 @@ export type SelectWithTooltipProps = Omit<
  *
  * Render a select with a button to display a tooltip about options.
  */
-const SelectWithTooltip: VFC<SelectWithTooltipProps> = ({
+const SelectWithTooltip: FC<SelectWithTooltipProps> = ({
   title,
   content,
   id,
diff --git a/src/components/molecules/forms/theme-toggle.tsx b/src/components/molecules/forms/theme-toggle.tsx
index eb56ce9..10c6c47 100644
--- a/src/components/molecules/forms/theme-toggle.tsx
+++ b/src/components/molecules/forms/theme-toggle.tsx
@@ -1,10 +1,10 @@
 import Moon from '@components/atoms/icons/moon';
 import Sun from '@components/atoms/icons/sun';
 import Toggle, {
-  ToggleChoices,
-  ToggleProps,
+  type ToggleChoices,
+  type ToggleProps,
 } from '@components/molecules/forms/toggle';
-import { useState, VFC } from 'react';
+import { FC, useState } from 'react';
 import { useIntl } from 'react-intl';
 
 export type ThemeToggleProps = Pick<ToggleProps, 'labelClassName' | 'value'>;
@@ -14,7 +14,7 @@ export type ThemeToggleProps = Pick<ToggleProps, 'labelClassName' | 'value'>;
  *
  * Render a Toggle component to set theme.
  */
-const ThemeToggle: VFC<ThemeToggleProps> = ({ value, ...props }) => {
+const ThemeToggle: FC<ThemeToggleProps> = ({ value, ...props }) => {
   const intl = useIntl();
   const [isDarkTheme, setIsDarkTheme] = useState<boolean>(value);
   const themeLabel = intl.formatMessage({
diff --git a/src/components/molecules/forms/toggle.tsx b/src/components/molecules/forms/toggle.tsx
index dff2d2d..288062d 100644
--- a/src/components/molecules/forms/toggle.tsx
+++ b/src/components/molecules/forms/toggle.tsx
@@ -1,6 +1,6 @@
-import Checkbox from '@components/atoms/forms/checkbox';
+import Checkbox, { type CheckboxProps } from '@components/atoms/forms/checkbox';
 import Label, { type LabelProps } from '@components/atoms/forms/label';
-import { ReactNode, VFC } from 'react';
+import { FC, ReactNode } from 'react';
 import styles from './toggle.module.scss';
 
 export type ToggleChoices = {
@@ -14,15 +14,11 @@ export type ToggleChoices = {
   right: ReactNode;
 };
 
-export type ToggleProps = {
+export type ToggleProps = Pick<CheckboxProps, 'id' | 'name'> & {
   /**
    * The toggle choices.
    */
   choices: ToggleChoices;
-  /**
-   * The input id.
-   */
-  id: string;
   /**
    * The toggle label.
    */
@@ -30,15 +26,11 @@ export type ToggleProps = {
   /**
    * Set additional classnames to the label.
    */
-  labelClassName?: string;
+  labelClassName?: LabelProps['className'];
   /**
    * The label size.
    */
   labelSize?: LabelProps['size'];
-  /**
-   * The input name.
-   */
-  name: string;
   /**
    * The toggle value. True if checked.
    */
@@ -54,7 +46,7 @@ export type ToggleProps = {
  *
  * Render a toggle with a label and two choices.
  */
-const Toggle: VFC<ToggleProps> = ({
+const Toggle: FC<ToggleProps> = ({
   choices,
   id,
   label,
diff --git a/src/components/molecules/images/responsive-image.tsx b/src/components/molecules/images/responsive-image.tsx
index 1d8787e..31cbcd1 100644
--- a/src/components/molecules/images/responsive-image.tsx
+++ b/src/components/molecules/images/responsive-image.tsx
@@ -1,6 +1,6 @@
-import Link from '@components/atoms/links/link';
-import Image, { ImageProps } from 'next/image';
-import { VFC } from 'react';
+import Link, { type LinkProps } from '@components/atoms/links/link';
+import Image, { type ImageProps } from 'next/image';
+import { FC } from 'react';
 import styles from './responsive-image.module.scss';
 
 export type ResponsiveImageProps = Omit<
@@ -26,7 +26,7 @@ export type ResponsiveImageProps = Omit<
   /**
    * A link target.
    */
-  target?: string;
+  target?: LinkProps['href'];
   /**
    * The image width.
    */
@@ -38,7 +38,7 @@ export type ResponsiveImageProps = Omit<
  *
  * Render a responsive image wrapped in a figure element.
  */
-const ResponsiveImage: VFC<ResponsiveImageProps> = ({
+const ResponsiveImage: FC<ResponsiveImageProps> = ({
   alt,
   caption,
   className = '',
diff --git a/src/components/molecules/layout/branding.tsx b/src/components/molecules/layout/branding.tsx
index 9f564bf..9fe89e7 100644
--- a/src/components/molecules/layout/branding.tsx
+++ b/src/components/molecules/layout/branding.tsx
@@ -1,11 +1,11 @@
 import Heading from '@components/atoms/headings/heading';
 import Link from 'next/link';
-import { VFC } from 'react';
+import { FC } from 'react';
 import { useIntl } from 'react-intl';
 import styles from './branding.module.scss';
-import FlippingLogo from './flipping-logo';
+import FlippingLogo, { type FlippingLogoProps } from './flipping-logo';
 
-type BrandingProps = {
+export type BrandingProps = Pick<FlippingLogoProps, 'photo'> & {
   /**
    * The Branding baseline.
    */
@@ -14,10 +14,6 @@ type BrandingProps = {
    * Use H1 if the current page is homepage. Default: false.
    */
   isHome?: boolean;
-  /**
-   * A photography URL.
-   */
-  photo: string;
   /**
    * The Branding title;
    */
@@ -33,7 +29,7 @@ type BrandingProps = {
  *
  * Render the branding logo, title and optional baseline.
  */
-const Branding: VFC<BrandingProps> = ({
+const Branding: FC<BrandingProps> = ({
   baseline,
   isHome = false,
   photo,
diff --git a/src/components/molecules/layout/card.tsx b/src/components/molecules/layout/card.tsx
index 23a0e54..89f100e 100644
--- a/src/components/molecules/layout/card.tsx
+++ b/src/components/molecules/layout/card.tsx
@@ -1,11 +1,11 @@
 import ButtonLink from '@components/atoms/buttons/button-link';
 import Heading, { type HeadingLevel } from '@components/atoms/headings/heading';
 import DescriptionList, {
-  DescriptionListItem,
+  type DescriptionListItem,
 } from '@components/atoms/lists/description-list';
-import { VFC } from 'react';
+import { FC } from 'react';
 import ResponsiveImage, {
-  ResponsiveImageProps,
+  type ResponsiveImageProps,
 } from '../images/responsive-image';
 import styles from './card.module.scss';
 
@@ -68,7 +68,7 @@ export type CardProps = {
  *
  * Render a link with minimal information about its content.
  */
-const Card: VFC<CardProps> = ({
+const Card: FC<CardProps> = ({
   className = '',
   cover,
   coverFit = 'cover',
diff --git a/src/components/molecules/layout/flipping-logo.tsx b/src/components/molecules/layout/flipping-logo.tsx
index 6f7645f..4a216ef 100644
--- a/src/components/molecules/layout/flipping-logo.tsx
+++ b/src/components/molecules/layout/flipping-logo.tsx
@@ -1,9 +1,9 @@
-import Logo from '@components/atoms/images/logo';
+import Logo, { type LogoProps } from '@components/atoms/images/logo';
 import Image from 'next/image';
-import { VFC } from 'react';
+import { FC } from 'react';
 import styles from './flipping-logo.module.scss';
 
-type FlippingLogoProps = {
+export type FlippingLogoProps = {
   /**
    * Set additional classnames to the logo wrapper.
    */
@@ -15,7 +15,7 @@ type FlippingLogoProps = {
   /**
    * Logo image title.
    */
-  logoTitle?: string;
+  logoTitle?: LogoProps['title'];
   /**
    * Photo url.
    */
@@ -27,7 +27,7 @@ type FlippingLogoProps = {
  *
  * Render a logo and a photo with a flipping effect.
  */
-const FlippingLogo: VFC<FlippingLogoProps> = ({
+const FlippingLogo: FC<FlippingLogoProps> = ({
   className = '',
   altText,
   logoTitle,
diff --git a/src/components/molecules/layout/meta.tsx b/src/components/molecules/layout/meta.tsx
index 218ebd9..fcce473 100644
--- a/src/components/molecules/layout/meta.tsx
+++ b/src/components/molecules/layout/meta.tsx
@@ -2,7 +2,7 @@ import DescriptionList, {
   type DescriptionListProps,
   type DescriptionListItem,
 } from '@components/atoms/lists/description-list';
-import { ReactNode, VFC } from 'react';
+import { FC, ReactNode } from 'react';
 
 export type MetaItem = {
   /**
@@ -43,7 +43,7 @@ export type MetaProps = {
  *
  * Renders the page metadata.
  */
-const Meta: VFC<MetaProps> = ({ data, ...props }) => {
+const Meta: FC<MetaProps> = ({ data, ...props }) => {
   /**
    * Transform the metadata to description list item format.
    *
diff --git a/src/components/molecules/layout/widget.tsx b/src/components/molecules/layout/widget.tsx
index c04362a..feb2add 100644
--- a/src/components/molecules/layout/widget.tsx
+++ b/src/components/molecules/layout/widget.tsx
@@ -1,11 +1,17 @@
-import { FC, useState } from 'react';
-import HeadingButton, { HeadingButtonProps } from '../buttons/heading-button';
+import { FC, ReactNode, useState } from 'react';
+import HeadingButton, {
+  type HeadingButtonProps,
+} from '../buttons/heading-button';
 import styles from './widget.module.scss';
 
 export type WidgetProps = Pick<
   HeadingButtonProps,
   'expanded' | 'level' | 'title'
 > & {
+  /**
+   * The widget body.
+   */
+  children: ReactNode;
   /**
    * Set additional classnames to the widget wrapper.
    */
diff --git a/src/components/molecules/modals/modal.test.tsx b/src/components/molecules/modals/modal.test.tsx
index 14fb224..9a0e237 100644
--- a/src/components/molecules/modals/modal.test.tsx
+++ b/src/components/molecules/modals/modal.test.tsx
@@ -1,9 +1,18 @@
 import { render, screen } from '@test-utils';
 import Modal from './modal';
 
+const title = 'A custom title';
+const children =
+  'Labore ullam delectus sit modi quam dolores. Ratione id sint aliquid facilis ipsum. Unde necessitatibus provident minus.';
+
 describe('Modal', () => {
   it('renders a title', () => {
-    render(<Modal title="A custom title" />);
-    expect(screen.getByText('A custom title')).toBeInTheDocument();
+    render(<Modal title={title}>{children}</Modal>);
+    expect(screen.getByText(title)).toBeInTheDocument();
+  });
+
+  it('renders the modal body', () => {
+    render(<Modal title={title}>{children}</Modal>);
+    expect(screen.getByText(children)).toBeInTheDocument();
   });
 });
diff --git a/src/components/molecules/modals/modal.tsx b/src/components/molecules/modals/modal.tsx
index 52ada57..58f5fa0 100644
--- a/src/components/molecules/modals/modal.tsx
+++ b/src/components/molecules/modals/modal.tsx
@@ -1,13 +1,17 @@
-import Heading from '@components/atoms/headings/heading';
-import { CogProps } from '@components/atoms/icons/cog';
-import { MagnifyingGlassProps } from '@components/atoms/icons/magnifying-glass';
+import Heading, { type HeadingProps } from '@components/atoms/headings/heading';
+import { type CogProps } from '@components/atoms/icons/cog';
+import { type MagnifyingGlassProps } from '@components/atoms/icons/magnifying-glass';
 import dynamic from 'next/dynamic';
-import { FC } from 'react';
+import { FC, ReactNode } from 'react';
 import styles from './modal.module.scss';
 
 export type Icons = 'cogs' | 'search';
 
 export type ModalProps = {
+  /**
+   * The modal body.
+   */
+  children: ReactNode;
   /**
    * Set additional classnames.
    */
@@ -15,7 +19,7 @@ export type ModalProps = {
   /**
    * Set additional classnames to the heading.
    */
-  headingClassName?: string;
+  headingClassName?: HeadingProps['className'];
   /**
    * A icon to illustrate the modal.
    */
diff --git a/src/components/molecules/modals/tooltip.tsx b/src/components/molecules/modals/tooltip.tsx
index 73f36e7..80721f3 100644
--- a/src/components/molecules/modals/tooltip.tsx
+++ b/src/components/molecules/modals/tooltip.tsx
@@ -1,5 +1,5 @@
 import List, { type ListItem } from '@components/atoms/lists/list';
-import { ReactNode, VFC } from 'react';
+import { FC, ReactNode } from 'react';
 import styles from './tooltip.module.scss';
 
 export type TooltipProps = {
@@ -26,7 +26,7 @@ export type TooltipProps = {
  *
  * Render a tooltip modal.
  */
-const Tooltip: VFC<TooltipProps> = ({
+const Tooltip: FC<TooltipProps> = ({
   className = '',
   content,
   icon,
diff --git a/src/components/molecules/nav/breadcrumb.tsx b/src/components/molecules/nav/breadcrumb.tsx
index 33af735..6dc86a0 100644
--- a/src/components/molecules/nav/breadcrumb.tsx
+++ b/src/components/molecules/nav/breadcrumb.tsx
@@ -1,7 +1,7 @@
 import Link from '@components/atoms/links/link';
 import { settings } from '@utils/config';
 import Script from 'next/script';
-import { VFC } from 'react';
+import { FC } from 'react';
 import { useIntl } from 'react-intl';
 import { BreadcrumbList, ListItem, WithContext } from 'schema-dts';
 import styles from './breadcrumb.module.scss';
@@ -37,7 +37,7 @@ export type BreadcrumbProps = {
  *
  * Render a breadcrumb navigation.
  */
-const Breadcrumb: VFC<BreadcrumbProps> = ({ items, ...props }) => {
+const Breadcrumb: FC<BreadcrumbProps> = ({ items, ...props }) => {
   const intl = useIntl();
 
   /**
diff --git a/src/components/molecules/nav/nav.tsx b/src/components/molecules/nav/nav.tsx
index 6ef9158..2666ea2 100644
--- a/src/components/molecules/nav/nav.tsx
+++ b/src/components/molecules/nav/nav.tsx
@@ -1,6 +1,6 @@
 import Link from '@components/atoms/links/link';
 import NavLink from '@components/atoms/links/nav-link';
-import { ReactNode, VFC } from 'react';
+import { FC, ReactNode } from 'react';
 import styles from './nav.module.scss';
 
 export type NavItem = {
@@ -46,7 +46,7 @@ export type NavProps = {
  *
  * Render the nav links.
  */
-const Nav: VFC<NavProps> = ({
+const Nav: FC<NavProps> = ({
   className = '',
   items,
   kind,
diff --git a/src/components/organisms/forms/comment-form.stories.tsx b/src/components/organisms/forms/comment-form.stories.tsx
index 1ab7cf2..670176c 100644
--- a/src/components/organisms/forms/comment-form.stories.tsx
+++ b/src/components/organisms/forms/comment-form.stories.tsx
@@ -1,4 +1,3 @@
-import Notice from '@components/atoms/layout/notice';
 import { ComponentMeta, ComponentStory } from '@storybook/react';
 import { IntlProvider } from 'react-intl';
 import CommentFormComponent from './comment-form';
diff --git a/src/components/organisms/forms/comment-form.tsx b/src/components/organisms/forms/comment-form.tsx
index 6acbf94..d7cb0f5 100644
--- a/src/components/organisms/forms/comment-form.tsx
+++ b/src/components/organisms/forms/comment-form.tsx
@@ -3,7 +3,7 @@ import Form 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';
-import { ReactNode, useState, VFC } from 'react';
+import { FC, ReactNode, useState } from 'react';
 import { useIntl } from 'react-intl';
 import styles from './comment-form.module.scss';
 
@@ -31,7 +31,7 @@ export type CommentFormProps = {
   titleLevel?: HeadingLevel;
 };
 
-const CommentForm: VFC<CommentFormProps> = ({
+const CommentForm: FC<CommentFormProps> = ({
   className = '',
   Notice,
   saveComment,
diff --git a/src/components/organisms/forms/contact-form.tsx b/src/components/organisms/forms/contact-form.tsx
index 994244a..4a6902b 100644
--- a/src/components/organisms/forms/contact-form.tsx
+++ b/src/components/organisms/forms/contact-form.tsx
@@ -2,7 +2,7 @@ import Button from '@components/atoms/buttons/button';
 import Form from '@components/atoms/forms/form';
 import Spinner from '@components/atoms/loaders/spinner';
 import LabelledField from '@components/molecules/forms/labelled-field';
-import { ReactNode, useState, VFC } from 'react';
+import { FC, ReactNode, useState } from 'react';
 import { useIntl } from 'react-intl';
 import styles from './contact-form.module.scss';
 
@@ -27,7 +27,7 @@ export type ContactFormProps = {
  *
  * Render a contact form.
  */
-const ContactForm: VFC<ContactFormProps> = ({
+const ContactForm: FC<ContactFormProps> = ({
   className = '',
   Notice,
   sendMail,
diff --git a/src/components/organisms/forms/search-form.tsx b/src/components/organisms/forms/search-form.tsx
index 351d93c..18b7c08 100644
--- a/src/components/organisms/forms/search-form.tsx
+++ b/src/components/organisms/forms/search-form.tsx
@@ -2,15 +2,20 @@ import Button from '@components/atoms/buttons/button';
 import Form from '@components/atoms/forms/form';
 import MagnifyingGlass from '@components/atoms/icons/magnifying-glass';
 import LabelledField, {
-  LabelledFieldProps,
+  type LabelledFieldProps,
 } from '@components/molecules/forms/labelled-field';
-import { useState, VFC } from 'react';
+import { FC, useState } from 'react';
 import { useIntl } from 'react-intl';
 import styles from './search-form.module.scss';
 
 export type SearchFormProps = Pick<LabelledFieldProps, 'hideLabel'>;
 
-const SearchForm: VFC<SearchFormProps> = ({ hideLabel }) => {
+/**
+ * SearchForm component
+ *
+ * Render a search form.
+ */
+const SearchForm: FC<SearchFormProps> = ({ hideLabel }) => {
   const intl = useIntl();
   const fieldLabel = intl.formatMessage({
     defaultMessage: 'Search for:',
diff --git a/src/components/organisms/layout/cards-list.tsx b/src/components/organisms/layout/cards-list.tsx
index a53df0d..33ffe23 100644
--- a/src/components/organisms/layout/cards-list.tsx
+++ b/src/components/organisms/layout/cards-list.tsx
@@ -3,7 +3,7 @@ import List, {
   type ListProps,
 } from '@components/atoms/lists/list';
 import Card, { type CardProps } from '@components/molecules/layout/card';
-import { VFC } from 'react';
+import { FC } from 'react';
 import styles from './cards-list.module.scss';
 
 export type CardsListItem = Omit<
@@ -37,7 +37,7 @@ export type CardsListProps = {
  *
  * Return a list of Card components.
  */
-const CardsList: VFC<CardsListProps> = ({
+const CardsList: FC<CardsListProps> = ({
   coverFit,
   items,
   kind = 'unordered',
diff --git a/src/components/organisms/layout/footer.tsx b/src/components/organisms/layout/footer.tsx
index c9cb067..15bfa24 100644
--- a/src/components/organisms/layout/footer.tsx
+++ b/src/components/organisms/layout/footer.tsx
@@ -1,7 +1,9 @@
-import Copyright, { CopyrightProps } from '@components/atoms/layout/copyright';
+import Copyright, {
+  type CopyrightProps,
+} from '@components/atoms/layout/copyright';
 import BackToTop from '@components/molecules/buttons/back-to-top';
 import Nav, { type NavItem } from '@components/molecules/nav/nav';
-import { VFC } from 'react';
+import { FC } from 'react';
 import styles from './footer.module.scss';
 
 export type FooterProps = {
@@ -28,12 +30,7 @@ export type FooterProps = {
  *
  * Renders a footer with copyright and nav;
  */
-const Footer: VFC<FooterProps> = ({
-  className,
-  copyright,
-  navItems,
-  topId,
-}) => {
+const Footer: FC<FooterProps> = ({ className, copyright, navItems, topId }) => {
   return (
     <footer className={`${styles.wrapper} ${className}`}>
       <Copyright
diff --git a/src/components/organisms/layout/overview.tsx b/src/components/organisms/layout/overview.tsx
index 3f83342..42d0431 100644
--- a/src/components/organisms/layout/overview.tsx
+++ b/src/components/organisms/layout/overview.tsx
@@ -2,7 +2,7 @@ import ResponsiveImage, {
   type ResponsiveImageProps,
 } from '@components/molecules/images/responsive-image';
 import Meta, { type MetaMap } from '@components/molecules/layout/meta';
-import { VFC } from 'react';
+import { FC } from 'react';
 import styles from './overview.module.scss';
 
 export type OverviewProps = {
@@ -15,7 +15,7 @@ export type OverviewProps = {
  *
  * Render an overview.
  */
-const Overview: VFC<OverviewProps> = ({ cover, meta }) => {
+const Overview: FC<OverviewProps> = ({ cover, meta }) => {
   return (
     <div className={styles.wrapper}>
       {cover && (
diff --git a/src/components/organisms/layout/summary.stories.tsx b/src/components/organisms/layout/summary.stories.tsx
index 5214d70..b33acde 100644
--- a/src/components/organisms/layout/summary.stories.tsx
+++ b/src/components/organisms/layout/summary.stories.tsx
@@ -103,7 +103,7 @@ Summary.args = {
   cover: {
     alt: 'A cover',
     height: 480,
-    url: 'http://placeimg.com/640/480',
+    src: 'http://placeimg.com/640/480',
     width: 640,
   },
   excerpt:
diff --git a/src/components/organisms/layout/summary.test.tsx b/src/components/organisms/layout/summary.test.tsx
index ce87c0c..4944805 100644
--- a/src/components/organisms/layout/summary.test.tsx
+++ b/src/components/organisms/layout/summary.test.tsx
@@ -4,7 +4,7 @@ import Summary from './summary';
 const cover = {
   alt: 'A cover',
   height: 480,
-  url: 'http://placeimg.com/640/480',
+  src: 'http://placeimg.com/640/480',
   width: 640,
 };
 
diff --git a/src/components/organisms/layout/summary.tsx b/src/components/organisms/layout/summary.tsx
index 3624e5d..733a660 100644
--- a/src/components/organisms/layout/summary.tsx
+++ b/src/components/organisms/layout/summary.tsx
@@ -2,18 +2,18 @@ import ButtonLink from '@components/atoms/buttons/button-link';
 import Heading, { type HeadingLevel } from '@components/atoms/headings/heading';
 import Arrow from '@components/atoms/icons/arrow';
 import Link from '@components/atoms/links/link';
-import ResponsiveImage from '@components/molecules/images/responsive-image';
+import ResponsiveImage, {
+  type ResponsiveImageProps,
+} from '@components/molecules/images/responsive-image';
 import Meta, { type MetaItem } from '@components/molecules/layout/meta';
-import { VFC } from 'react';
+import { FC } from 'react';
 import { useIntl } from 'react-intl';
 import styles from './summary.module.scss';
 
-export type Cover = {
-  alt: string;
-  height: number;
-  url: string;
-  width: number;
-};
+export type Cover = Pick<
+  ResponsiveImageProps,
+  'alt' | 'src' | 'width' | 'height'
+>;
 
 export type RequiredMetaKey = 'publication';
 
@@ -35,11 +35,29 @@ export type OptionalMeta = {
 export type Meta = RequiredMeta & OptionalMeta;
 
 export type SummaryProps = {
+  /**
+   * The post cover.
+   */
   cover?: Cover;
+  /**
+   * The post excerpt.
+   */
   excerpt: string;
+  /**
+   * The post meta.
+   */
   meta: Meta;
+  /**
+   * The post title.
+   */
   title: string;
+  /**
+   * The heading level (hn).
+   */
   titleLevel?: HeadingLevel;
+  /**
+   * The post url.
+   */
   url: string;
 };
 
@@ -48,7 +66,7 @@ export type SummaryProps = {
  *
  * Render a page summary.
  */
-const Summary: VFC<SummaryProps> = ({
+const Summary: FC<SummaryProps> = ({
   cover,
   excerpt,
   meta,
@@ -57,18 +75,23 @@ const Summary: VFC<SummaryProps> = ({
   url,
 }) => {
   const intl = useIntl();
+  const readMore = intl.formatMessage(
+    {
+      defaultMessage: 'Read more<a11y> about {title}</a11y>',
+      description: 'Summary: read more link',
+      id: 'Zpgv+f',
+    },
+    {
+      title,
+      a11y: (chunks: string) => (
+        <span className="screen-reader-text">{chunks}</span>
+      ),
+    }
+  );
 
   return (
     <article className={styles.wrapper}>
-      {cover && (
-        <ResponsiveImage
-          alt={cover.alt}
-          src={cover.url}
-          width={cover.width}
-          height={cover.height}
-          className={styles.cover}
-        />
-      )}
+      {cover && <ResponsiveImage className={styles.cover} {...cover} />}
       <header className={styles.header}>
         <Link href={url}>
           <Heading level={titleLevel} className={styles.title}>
@@ -79,20 +102,10 @@ const Summary: VFC<SummaryProps> = ({
       <div className={styles.body}>
         {excerpt}
         <ButtonLink target={url} className={styles['read-more']}>
-          {intl.formatMessage(
-            {
-              defaultMessage: 'Read more<a11y> about {title}</a11y>',
-              description: 'Summary: read more link',
-              id: 'Zpgv+f',
-            },
-            {
-              title,
-              a11y: (chunks: string) => (
-                <span className="screen-reader-text">{chunks}</span>
-              ),
-            }
-          )}
-          <Arrow direction="right" />
+          <>
+            {readMore}
+            <Arrow direction="right" />
+          </>
         </ButtonLink>
       </div>
       <footer className={styles.footer}>
diff --git a/src/components/organisms/modals/search-modal.tsx b/src/components/organisms/modals/search-modal.tsx
index 883b783..0e0ceed 100644
--- a/src/components/organisms/modals/search-modal.tsx
+++ b/src/components/organisms/modals/search-modal.tsx
@@ -1,5 +1,5 @@
-import Modal from '@components/molecules/modals/modal';
-import { VFC } from 'react';
+import Modal, { type ModalProps } from '@components/molecules/modals/modal';
+import { FC } from 'react';
 import { useIntl } from 'react-intl';
 import SearchForm from '../forms/search-form';
 import styles from './search-modal.module.scss';
@@ -8,7 +8,7 @@ export type SearchModalProps = {
   /**
    * Set additional classnames to modal wrapper.
    */
-  className?: string;
+  className?: ModalProps['className'];
 };
 
 /**
@@ -16,7 +16,7 @@ export type SearchModalProps = {
  *
  * Render a search form modal.
  */
-const SearchModal: VFC<SearchModalProps> = ({ className }) => {
+const SearchModal: FC<SearchModalProps> = ({ className }) => {
   const intl = useIntl();
   const modalTitle = intl.formatMessage({
     defaultMessage: 'Search',
diff --git a/src/components/organisms/modals/settings-modal.tsx b/src/components/organisms/modals/settings-modal.tsx
index 25d6f6f..20d2605 100644
--- a/src/components/organisms/modals/settings-modal.tsx
+++ b/src/components/organisms/modals/settings-modal.tsx
@@ -1,10 +1,12 @@
 import Form from '@components/atoms/forms/form';
-import AckeeSelect from '@components/molecules/forms/ackee-select';
+import AckeeSelect, {
+  type AckeeSelectProps,
+} from '@components/molecules/forms/ackee-select';
 import MotionToggle from '@components/molecules/forms/motion-toggle';
 import PrismThemeToggle from '@components/molecules/forms/prism-theme-toggle';
 import ThemeToggle from '@components/molecules/forms/theme-toggle';
-import Modal from '@components/molecules/modals/modal';
-import { VFC } from 'react';
+import Modal, { type ModalProps } from '@components/molecules/modals/modal';
+import { FC } from 'react';
 import { useIntl } from 'react-intl';
 import styles from './settings-modal.module.scss';
 
@@ -12,11 +14,11 @@ export type SettingsModalProps = {
   /**
    * Set additional classnames to the modal wrapper.
    */
-  className?: string;
+  className?: ModalProps['className'];
   /**
    * Set additional classnames to the tooltip wrapper.
    */
-  tooltipClassName?: string;
+  tooltipClassName?: AckeeSelectProps['tooltipClassName'];
 };
 
 /**
@@ -24,7 +26,7 @@ export type SettingsModalProps = {
  *
  * Render a modal with settings options.
  */
-const SettingsModal: VFC<SettingsModalProps> = ({
+const SettingsModal: FC<SettingsModalProps> = ({
   className = '',
   tooltipClassName = '',
 }) => {
diff --git a/src/components/organisms/toolbar/main-nav.tsx b/src/components/organisms/toolbar/main-nav.tsx
index 50bbe8b..35e3fd6 100644
--- a/src/components/organisms/toolbar/main-nav.tsx
+++ b/src/components/organisms/toolbar/main-nav.tsx
@@ -1,17 +1,20 @@
 import Checkbox, { type CheckboxProps } from '@components/atoms/forms/checkbox';
 import Label from '@components/atoms/forms/label';
 import Hamburger from '@components/atoms/icons/hamburger';
-import Nav, { type NavItem } from '@components/molecules/nav/nav';
-import { VFC } from 'react';
+import Nav, {
+  type NavProps,
+  type NavItem,
+} from '@components/molecules/nav/nav';
+import { FC } from 'react';
 import { useIntl } from 'react-intl';
-import sharedStyles from './toolbar-items.module.scss';
 import mainNavStyles from './main-nav.module.scss';
+import sharedStyles from './toolbar-items.module.scss';
 
 export type MainNavProps = {
   /**
    * Set additional classnames to the nav element.
    */
-  className?: string;
+  className?: NavProps['className'];
   /**
    * The button state.
    */
@@ -26,7 +29,12 @@ export type MainNavProps = {
   setIsActive: CheckboxProps['setValue'];
 };
 
-const MainNav: VFC<MainNavProps> = ({
+/**
+ * MainNav component
+ *
+ * Render the main navigation.
+ */
+const MainNav: FC<MainNavProps> = ({
   className = '',
   isActive,
   items,
diff --git a/src/components/organisms/toolbar/search.tsx b/src/components/organisms/toolbar/search.tsx
index 070bce0..72cd576 100644
--- a/src/components/organisms/toolbar/search.tsx
+++ b/src/components/organisms/toolbar/search.tsx
@@ -1,17 +1,17 @@
-import Checkbox, { CheckboxProps } from '@components/atoms/forms/checkbox';
+import Checkbox, { type CheckboxProps } from '@components/atoms/forms/checkbox';
 import Label from '@components/atoms/forms/label';
 import MagnifyingGlass from '@components/atoms/icons/magnifying-glass';
-import { VFC } from 'react';
+import { FC } from 'react';
 import { useIntl } from 'react-intl';
-import SearchModal from '../modals/search-modal';
-import sharedStyles from './toolbar-items.module.scss';
+import SearchModal, { type SearchModalProps } from '../modals/search-modal';
 import searchStyles from './search.module.scss';
+import sharedStyles from './toolbar-items.module.scss';
 
 export type SearchProps = {
   /**
    * Set additional classnames to the modal wrapper.
    */
-  className?: string;
+  className?: SearchModalProps['className'];
   /**
    * The button state.
    */
@@ -22,11 +22,7 @@ export type SearchProps = {
   setIsActive: CheckboxProps['setValue'];
 };
 
-const Search: VFC<SearchProps> = ({
-  className = '',
-  isActive,
-  setIsActive,
-}) => {
+const Search: FC<SearchProps> = ({ className = '', isActive, setIsActive }) => {
   const intl = useIntl();
   const label = isActive
     ? intl.formatMessage({
diff --git a/src/components/organisms/toolbar/settings.tsx b/src/components/organisms/toolbar/settings.tsx
index 88539fb..3b10226 100644
--- a/src/components/organisms/toolbar/settings.tsx
+++ b/src/components/organisms/toolbar/settings.tsx
@@ -1,11 +1,13 @@
-import Checkbox, { CheckboxProps } from '@components/atoms/forms/checkbox';
+import Checkbox, { type CheckboxProps } from '@components/atoms/forms/checkbox';
 import Label from '@components/atoms/forms/label';
 import Cog from '@components/atoms/icons/cog';
-import { VFC } from 'react';
+import { FC } from 'react';
 import { useIntl } from 'react-intl';
-import SettingsModal from '../modals/settings-modal';
-import sharedStyles from './toolbar-items.module.scss';
+import SettingsModal, {
+  type SettingsModalProps,
+} from '../modals/settings-modal';
 import settingsStyles from './settings.module.scss';
+import sharedStyles from './toolbar-items.module.scss';
 
 export type SettingsProps = {
   /**
@@ -23,10 +25,10 @@ export type SettingsProps = {
   /**
    * Set additional classnames to the tooltip wrapper.
    */
-  tooltipClassName?: string;
+  tooltipClassName?: SettingsModalProps['tooltipClassName'];
 };
 
-const Settings: VFC<SettingsProps> = ({
+const Settings: FC<SettingsProps> = ({
   className = '',
   isActive,
   setIsActive,
diff --git a/src/components/organisms/toolbar/toolbar.tsx b/src/components/organisms/toolbar/toolbar.tsx
index 81e80cf..f1ce530 100644
--- a/src/components/organisms/toolbar/toolbar.tsx
+++ b/src/components/organisms/toolbar/toolbar.tsx
@@ -1,4 +1,4 @@
-import { useState, VFC } from 'react';
+import { FC, useState } from 'react';
 import MainNav, { type MainNavProps } from '../toolbar/main-nav';
 import Search from '../toolbar/search';
 import Settings from '../toolbar/settings';
@@ -20,7 +20,7 @@ export type ToolbarProps = {
  *
  * Render the website toolbar.
  */
-const Toolbar: VFC<ToolbarProps> = ({ className = '', nav }) => {
+const Toolbar: FC<ToolbarProps> = ({ className = '', nav }) => {
   const [isNavOpened, setIsNavOpened] = useState<boolean>(false);
   const [isSettingsOpened, setIsSettingsOpened] = useState<boolean>(false);
   const [isSearchOpened, setIsSearchOpened] = useState<boolean>(false);
diff --git a/src/components/organisms/widgets/image-widget.tsx b/src/components/organisms/widgets/image-widget.tsx
index 928d5ea..ba04c6a 100644
--- a/src/components/organisms/widgets/image-widget.tsx
+++ b/src/components/organisms/widgets/image-widget.tsx
@@ -1,28 +1,16 @@
-import ResponsiveImage from '@components/molecules/images/responsive-image';
+import ResponsiveImage, {
+  type ResponsiveImageProps,
+} from '@components/molecules/images/responsive-image';
 import Widget, { type WidgetProps } from '@components/molecules/layout/widget';
-import { VFC } from 'react';
+import { FC } from 'react';
 import styles from './image-widget.module.scss';
 
 export type Alignment = 'left' | 'center' | 'right';
 
-export type Image = {
-  /**
-   * An alternative text for the image.
-   */
-  alt: string;
-  /**
-   * The image height.
-   */
-  height: number;
-  /**
-   * The image source.
-   */
-  src: string;
-  /**
-   * The image width.
-   */
-  width: number;
-};
+export type Image = Pick<
+  ResponsiveImageProps,
+  'alt' | 'height' | 'src' | 'width'
+>;
 
 export type ImageWidgetProps = Pick<
   WidgetProps,
@@ -35,7 +23,7 @@ export type ImageWidgetProps = Pick<
   /**
    * Add a caption to the image.
    */
-  description?: string;
+  description?: ResponsiveImageProps['caption'];
   /**
    * An object describing the image.
    */
@@ -43,7 +31,7 @@ export type ImageWidgetProps = Pick<
   /**
    * Add a link to the image.
    */
-  url?: string;
+  url?: ResponsiveImageProps['target'];
 };
 
 /**
@@ -51,7 +39,7 @@ export type ImageWidgetProps = Pick<
  *
  * Renders a widget that print an image and an optional text.
  */
-const ImageWidget: VFC<ImageWidgetProps> = ({
+const ImageWidget: FC<ImageWidgetProps> = ({
   alignment = 'left',
   description,
   img,
diff --git a/src/components/organisms/widgets/links-list-widget.tsx b/src/components/organisms/widgets/links-list-widget.tsx
index 155354e..559d0b6 100644
--- a/src/components/organisms/widgets/links-list-widget.tsx
+++ b/src/components/organisms/widgets/links-list-widget.tsx
@@ -1,8 +1,11 @@
 import Link from '@components/atoms/links/link';
-import List, { ListProps, type ListItem } from '@components/atoms/lists/list';
+import List, {
+  type ListProps,
+  type ListItem,
+} from '@components/atoms/lists/list';
 import Widget, { type WidgetProps } from '@components/molecules/layout/widget';
 import { slugify } from '@utils/helpers/slugify';
-import { VFC } from 'react';
+import { FC } from 'react';
 import styles from './links-list-widget.module.scss';
 
 export type LinksListItems = {
@@ -33,7 +36,7 @@ export type LinksListWidgetProps = Pick<WidgetProps, 'level' | 'title'> &
  *
  * Render a list of links inside a widget.
  */
-const LinksListWidget: VFC<LinksListWidgetProps> = ({
+const LinksListWidget: FC<LinksListWidgetProps> = ({
   items,
   kind = 'unordered',
   ...props
diff --git a/src/components/organisms/widgets/sharing.tsx b/src/components/organisms/widgets/sharing.tsx
index ccd3a21..571c1a4 100644
--- a/src/components/organisms/widgets/sharing.tsx
+++ b/src/components/organisms/widgets/sharing.tsx
@@ -2,7 +2,7 @@ import SharingLink, {
   type SharingMedium,
 } from '@components/atoms/links/sharing-link';
 import Widget, { type WidgetProps } from '@components/molecules/layout/widget';
-import { VFC } from 'react';
+import { FC } from 'react';
 import { useIntl } from 'react-intl';
 import styles from './sharing.module.scss';
 
@@ -21,7 +21,7 @@ export type SharingData = {
   url: string;
 };
 
-export type SharingProps = WidgetProps & {
+export type SharingProps = Omit<WidgetProps, 'children'> & {
   /**
    * The page data to share.
    */
@@ -37,7 +37,7 @@ export type SharingProps = WidgetProps & {
  *
  * Render a list of sharing links inside a widget.
  */
-const Sharing: VFC<SharingProps> = ({ data, media, ...props }) => {
+const Sharing: FC<SharingProps> = ({ data, media, ...props }) => {
   const intl = useIntl();
 
   /**
-- 
cgit v1.2.3


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

---
 src/components/atoms/layout/sidebar.module.scss    |   5 +
 src/components/atoms/layout/sidebar.tsx            |   6 +-
 src/components/molecules/layout/meta.module.scss   |  17 +
 src/components/molecules/layout/meta.tsx           |  14 +-
 .../molecules/nav/breadcrumb.stories.tsx           |  13 +
 src/components/molecules/nav/breadcrumb.tsx        |  22 +-
 .../organisms/layout/comment.stories.tsx           |  10 -
 src/components/organisms/layout/header.module.scss |   2 +-
 .../organisms/layout/posts-list.module.scss        |  11 +-
 .../organisms/layout/posts-list.stories.tsx        |   7 +
 .../organisms/layout/summary.module.scss           |   2 +
 src/components/templates/layout/layout.module.scss |   1 -
 .../templates/page/page-layout.module.scss         |  85 ++++
 .../templates/page/page-layout.stories.tsx         | 520 +++++++++++++++++++++
 src/components/templates/page/page-layout.test.tsx |  90 ++++
 src/components/templates/page/page-layout.tsx      | 166 +++++++
 src/styles/abstracts/_placeholders.scss            |   1 +
 src/styles/abstracts/placeholders/_layout.scss     |  25 +
 src/utils/helpers/format.ts                        |  28 +-
 src/utils/hooks/use-is-mounted.tsx                 |  19 +
 20 files changed, 1019 insertions(+), 25 deletions(-)
 create mode 100644 src/components/molecules/layout/meta.module.scss
 create mode 100644 src/components/templates/page/page-layout.module.scss
 create mode 100644 src/components/templates/page/page-layout.stories.tsx
 create mode 100644 src/components/templates/page/page-layout.test.tsx
 create mode 100644 src/components/templates/page/page-layout.tsx
 create mode 100644 src/styles/abstracts/placeholders/_layout.scss
 create mode 100644 src/utils/hooks/use-is-mounted.tsx

(limited to 'src/components/molecules/layout/meta.tsx')

diff --git a/src/components/atoms/layout/sidebar.module.scss b/src/components/atoms/layout/sidebar.module.scss
index da2acbe..5d36f18 100644
--- a/src/components/atoms/layout/sidebar.module.scss
+++ b/src/components/atoms/layout/sidebar.module.scss
@@ -5,3 +5,8 @@
     margin-top: fun.convert-px(-2);
   }
 }
+
+.body {
+  position: sticky;
+  top: var(--spacing-xs);
+}
diff --git a/src/components/atoms/layout/sidebar.tsx b/src/components/atoms/layout/sidebar.tsx
index 194ed9f..d13cc0d 100644
--- a/src/components/atoms/layout/sidebar.tsx
+++ b/src/components/atoms/layout/sidebar.tsx
@@ -18,7 +18,11 @@ export type SidebarProps = {
  * Render an aside element.
  */
 const Sidebar: FC<SidebarProps> = ({ children, className = '' }) => {
-  return <aside className={`${styles.wrapper} ${className}`}>{children}</aside>;
+  return (
+    <aside className={`${styles.wrapper} ${className}`}>
+      <div className={styles.body}>{children}</div>
+    </aside>
+  );
 };
 
 export default Sidebar;
diff --git a/src/components/molecules/layout/meta.module.scss b/src/components/molecules/layout/meta.module.scss
new file mode 100644
index 0000000..f7cc55b
--- /dev/null
+++ b/src/components/molecules/layout/meta.module.scss
@@ -0,0 +1,17 @@
+@use "@styles/abstracts/mixins" as mix;
+
+.list {
+  display: grid;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+
+  @include mix.media("screen") {
+    @include mix.dimensions("sm") {
+      display: flex;
+      flex-flow: column nowrap;
+    }
+  }
+}
+
+.value {
+  word-break: break-all;
+}
diff --git a/src/components/molecules/layout/meta.tsx b/src/components/molecules/layout/meta.tsx
index fcce473..d05396e 100644
--- a/src/components/molecules/layout/meta.tsx
+++ b/src/components/molecules/layout/meta.tsx
@@ -3,6 +3,7 @@ import DescriptionList, {
   type DescriptionListItem,
 } from '@components/atoms/lists/description-list';
 import { FC, ReactNode } from 'react';
+import styles from './meta.module.scss';
 
 export type MetaItem = {
   /**
@@ -23,7 +24,7 @@ export type MetaProps = {
   /**
    * Set additional classnames to the meta wrapper.
    */
-  className?: string;
+  className?: DescriptionListProps['className'];
   /**
    * The meta data.
    */
@@ -43,7 +44,7 @@ export type MetaProps = {
  *
  * Renders the page metadata.
  */
-const Meta: FC<MetaProps> = ({ data, ...props }) => {
+const Meta: FC<MetaProps> = ({ className, data, ...props }) => {
   /**
    * Transform the metadata to description list item format.
    *
@@ -68,7 +69,14 @@ const Meta: FC<MetaProps> = ({ data, ...props }) => {
     return listItems;
   };
 
-  return <DescriptionList items={getItems(data)} {...props} />;
+  return (
+    <DescriptionList
+      items={getItems(data)}
+      className={`${styles.list} ${className}`}
+      descriptionClassName={styles.value}
+      {...props}
+    />
+  );
 };
 
 export default Meta;
diff --git a/src/components/molecules/nav/breadcrumb.stories.tsx b/src/components/molecules/nav/breadcrumb.stories.tsx
index 500ae6c..e26b480 100644
--- a/src/components/molecules/nav/breadcrumb.stories.tsx
+++ b/src/components/molecules/nav/breadcrumb.stories.tsx
@@ -22,6 +22,19 @@ export default {
         required: false,
       },
     },
+    itemClassName: {
+      control: {
+        type: 'text',
+      },
+      table: {
+        category: 'Styles',
+      },
+      description: 'Set additional classnames to the breadcrumb items.',
+      type: {
+        name: 'string',
+        required: false,
+      },
+    },
     items: {
       description: 'The breadcrumb items.',
       type: {
diff --git a/src/components/molecules/nav/breadcrumb.tsx b/src/components/molecules/nav/breadcrumb.tsx
index 6dc86a0..d184d65 100644
--- a/src/components/molecules/nav/breadcrumb.tsx
+++ b/src/components/molecules/nav/breadcrumb.tsx
@@ -26,6 +26,10 @@ export type BreadcrumbProps = {
    * Set additional classnames to the nav element.
    */
   className?: string;
+  /**
+   * Set additional classnames to the breadcrumb items.
+   */
+  itemClassName?: string;
   /**
    * The breadcrumb items
    */
@@ -37,9 +41,19 @@ export type BreadcrumbProps = {
  *
  * Render a breadcrumb navigation.
  */
-const Breadcrumb: FC<BreadcrumbProps> = ({ items, ...props }) => {
+const Breadcrumb: FC<BreadcrumbProps> = ({
+  itemClassName = '',
+  items,
+  ...props
+}) => {
   const intl = useIntl();
 
+  const ariaLabel = intl.formatMessage({
+    defaultMessage: 'Breadcrumb',
+    description: 'Breadcrumb: an accessible name for the breadcrumb nav.',
+    id: '28nnDY',
+  });
+
   /**
    * Retrieve the breadcrumb list items.
    *
@@ -49,12 +63,12 @@ const Breadcrumb: FC<BreadcrumbProps> = ({ items, ...props }) => {
   const getListItems = (list: BreadcrumbItem[]): JSX.Element[] => {
     return list.map((item, index) => {
       const isLastItem = index === list.length - 1;
-      const itemClassnames = isLastItem
+      const itemStyles = isLastItem
         ? `${styles.item} screen-reader-text`
         : styles.item;
 
       return (
-        <li key={item.id} className={itemClassnames}>
+        <li key={item.id} className={`${itemStyles} ${itemClassName}`}>
           {isLastItem ? item.name : <Link href={item.url}>{item.name}</Link>}
         </li>
       );
@@ -96,7 +110,7 @@ const Breadcrumb: FC<BreadcrumbProps> = ({ items, ...props }) => {
         type="application/ld+json"
         dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }}
       />
-      <nav {...props}>
+      <nav aria-label={ariaLabel} {...props}>
         <span className="screen-reader-text">
           {intl.formatMessage({
             defaultMessage: 'You are here:',
diff --git a/src/components/organisms/layout/comment.stories.tsx b/src/components/organisms/layout/comment.stories.tsx
index b14621b..3794b06 100644
--- a/src/components/organisms/layout/comment.stories.tsx
+++ b/src/components/organisms/layout/comment.stories.tsx
@@ -51,16 +51,6 @@ export default {
         required: true,
       },
     },
-    postId: {
-      control: {
-        type: 'number',
-      },
-      description: 'The post id.',
-      type: {
-        name: 'number',
-        required: true,
-      },
-    },
     publication: {
       description: 'The publication date.',
       type: {
diff --git a/src/components/organisms/layout/header.module.scss b/src/components/organisms/layout/header.module.scss
index 7ae683f..a98cf45 100644
--- a/src/components/organisms/layout/header.module.scss
+++ b/src/components/organisms/layout/header.module.scss
@@ -7,7 +7,7 @@
     minmax(0, 1fr) min(calc(100vw - calc(var(--spacing-md) * 2)), 100ch)
     minmax(0, 1fr);
   align-items: center;
-  padding: var(--spacing-sm) 0 var(--spacing-md);
+  padding: var(--spacing-md) 0 var(--spacing-lg);
 
   .toolbar {
     justify-content: space-around;
diff --git a/src/components/organisms/layout/posts-list.module.scss b/src/components/organisms/layout/posts-list.module.scss
index 4d80442..f072082 100644
--- a/src/components/organisms/layout/posts-list.module.scss
+++ b/src/components/organisms/layout/posts-list.module.scss
@@ -1,3 +1,4 @@
+@use "@styles/abstracts/functions" as fun;
 @use "@styles/abstracts/mixins" as mix;
 @use "@styles/abstracts/placeholders";
 
@@ -5,8 +6,9 @@
   @include mix.media("screen") {
     @include mix.dimensions("md") {
       display: grid;
-      grid-template-columns: max-content minmax(0, 1fr);
+      grid-template-columns: fun.convert-px(150) minmax(0, 1fr);
       align-items: first baseline;
+      margin-left: fun.convert-px(-150);
     }
   }
 }
@@ -16,6 +18,7 @@
 
   .item {
     margin-bottom: var(--spacing-md);
+    border-bottom: fun.convert-px(1) solid var(--color-border);
   }
 }
 
@@ -24,9 +27,13 @@
     @include mix.dimensions("md") {
       grid-column: 1;
       justify-self: end;
-      margin-right: var(--spacing-lg);
+      padding-right: var(--spacing-lg);
       position: sticky;
       top: var(--spacing-xs);
     }
+
+    @include mix.dimensions("lg") {
+      padding-right: var(--spacing-xl);
+    }
   }
 }
diff --git a/src/components/organisms/layout/posts-list.stories.tsx b/src/components/organisms/layout/posts-list.stories.tsx
index 783d333..f80e1ca 100644
--- a/src/components/organisms/layout/posts-list.stories.tsx
+++ b/src/components/organisms/layout/posts-list.stories.tsx
@@ -171,6 +171,13 @@ ByYears.args = {
   posts,
   byYear: true,
 };
+ByYears.decorators = [
+  (Story) => (
+    <div style={{ marginLeft: 150 }}>
+      <Story />
+    </div>
+  ),
+];
 
 /**
  * PostsList Stories - No results
diff --git a/src/components/organisms/layout/summary.module.scss b/src/components/organisms/layout/summary.module.scss
index 3919e15..1cdda98 100644
--- a/src/components/organisms/layout/summary.module.scss
+++ b/src/components/organisms/layout/summary.module.scss
@@ -2,6 +2,8 @@
 @use "@styles/abstracts/mixins" as mix;
 
 .wrapper {
+  padding: var(--spacing-2xs) 0 var(--spacing-lg);
+
   @include mix.media("screen") {
     @include mix.dimensions("xs") {
       padding: var(--spacing-sm) var(--spacing-sm) var(--spacing-md);
diff --git a/src/components/templates/layout/layout.module.scss b/src/components/templates/layout/layout.module.scss
index eb84c70..3533257 100644
--- a/src/components/templates/layout/layout.module.scss
+++ b/src/components/templates/layout/layout.module.scss
@@ -11,7 +11,6 @@
 }
 
 .header {
-  padding: var(--spacing-sm) 0 var(--spacing-md);
   border-bottom: fun.convert-px(3) solid var(--color-border-light);
 }
 
diff --git a/src/components/templates/page/page-layout.module.scss b/src/components/templates/page/page-layout.module.scss
new file mode 100644
index 0000000..d5a1a2b
--- /dev/null
+++ b/src/components/templates/page/page-layout.module.scss
@@ -0,0 +1,85 @@
+@use "@styles/abstracts/functions" as fun;
+@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;
+
+  grid-column: 1 / -1;
+  padding: var(--spacing-md) 0;
+
+  > * {
+    grid-column: 2;
+  }
+
+  &__items {
+    font-size: var(--font-size-sm);
+  }
+}
+
+.header {
+  grid-column: 1 / -1;
+  margin-bottom: var(--spacing-md);
+}
+
+.body {
+  grid-column: 2;
+}
+
+.sidebar {
+  grid-column: 2;
+
+  &--first {
+    margin-bottom: var(--spacing-xs);
+
+    @include mix.media("screen") {
+      @include mix.dimensions("lg") {
+        grid-column: 1;
+        align-self: stretch;
+        margin: 0 var(--spacing-xs) var(--spacing-md);
+      }
+    }
+  }
+
+  &--last {
+    margin: var(--spacing-lg) 0 0;
+
+    @include mix.media("screen") {
+      @include mix.dimensions("md") {
+        grid-column: 3;
+        align-self: stretch;
+        margin: 0 var(--spacing-xs) var(--spacing-md);
+      }
+    }
+  }
+}
+
+.footer {
+  grid-column: 2;
+}
+
+.comments {
+  @extend %grid;
+
+  grid-column: 1 / -1;
+  margin: var(--spacing-lg) 0 0;
+  padding: 0 0 var(--spacing-lg);
+  background: var(--color-bg-secondary);
+  border-top: fun.convert-px(3) solid var(--color-border-light);
+
+  &__section {
+    grid-column: 2;
+    margin: var(--spacing-md) 0 0;
+  }
+}
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}
+    />,
+  ],
+};
diff --git a/src/components/templates/page/page-layout.test.tsx b/src/components/templates/page/page-layout.test.tsx
new file mode 100644
index 0000000..b8fff6a
--- /dev/null
+++ b/src/components/templates/page/page-layout.test.tsx
@@ -0,0 +1,90 @@
+import { render, screen } from '@test-utils';
+import PageLayout from './page-layout';
+
+const title = 'Incidunt ad earum';
+const breadcrumb = [
+  { id: 'home', url: '#', name: 'Home' },
+  { id: 'page', url: '#', name: title },
+];
+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}>
+        {children}
+      </PageLayout>
+    );
+    expect(
+      screen.getByRole('heading', { level: 1, name: title })
+    ).toBeInTheDocument();
+  });
+
+  it('renders the page content', () => {
+    render(
+      <PageLayout breadcrumb={breadcrumb} title={title}>
+        {children}
+      </PageLayout>
+    );
+    expect(screen.getByText(children)).toBeInTheDocument();
+  });
+
+  it('renders the breadcrumb', () => {
+    render(
+      <PageLayout breadcrumb={breadcrumb} title={title}>
+        {children}
+      </PageLayout>
+    );
+    expect(
+      screen.getByRole('navigation', { name: 'Breadcrumb' })
+    ).toBeInTheDocument();
+  });
+
+  it('renders the table of contents', () => {
+    render(
+      <PageLayout breadcrumb={breadcrumb} title={title} withToC={true}>
+        {children}
+      </PageLayout>
+    );
+    expect(
+      screen.getByRole('heading', { level: 2, name: /Table of Contents/i })
+    ).toBeInTheDocument();
+  });
+
+  it('renders the comment form', () => {
+    render(
+      <PageLayout breadcrumb={breadcrumb} title={title} allowComments={true}>
+        {children}
+      </PageLayout>
+    );
+    expect(
+      screen.getByRole('form', { name: /Leave a comment/i })
+    ).toBeInTheDocument();
+  });
+
+  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',
+        // @ts-ignore - Needed because of the placeholder image.
+        unoptimized: true,
+      },
+    ];
+    render(
+      <PageLayout breadcrumb={breadcrumb} title={title} comments={comments}>
+        {children}
+      </PageLayout>
+    );
+    expect(
+      screen.getByRole('heading', { level: 2, name: /Comments/i })
+    ).toBeInTheDocument();
+  });
+});
diff --git a/src/components/templates/page/page-layout.tsx b/src/components/templates/page/page-layout.tsx
new file mode 100644
index 0000000..24c4e50
--- /dev/null
+++ b/src/components/templates/page/page-layout.tsx
@@ -0,0 +1,166 @@
+import Heading from '@components/atoms/headings/heading';
+import Sidebar from '@components/atoms/layout/sidebar';
+import PageFooter, {
+  type PageFooterProps,
+} from '@components/molecules/layout/page-footer';
+import PageHeader, {
+  type PageHeaderProps,
+} from '@components/molecules/layout/page-header';
+import Breadcrumb, {
+  type BreadcrumbItem,
+} from '@components/molecules/nav/breadcrumb';
+import CommentForm 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 useIsMounted from '@utils/hooks/use-is-mounted';
+import { FC, ReactNode, useRef } from 'react';
+import { useIntl } from 'react-intl';
+import Layout, { LayoutProps } from '../layout/layout';
+import styles from './page-layout.module.scss';
+
+export type PageLayoutProps = {
+  /**
+   * True if the page accepts new comments. Default: false.
+   */
+  allowComments?: boolean;
+  /**
+   * The breadcrumb items.
+   */
+  breadcrumb: BreadcrumbItem[];
+  /**
+   * The main content of the page.
+   */
+  children: ReactNode;
+  /**
+   * The page comments
+   */
+  comments?: CommentsListProps['comments'];
+  /**
+   * The footer metadata.
+   */
+  footerMeta?: PageFooterProps['meta'];
+  /**
+   * The header metadata.
+   */
+  headerMeta?: PageHeaderProps['meta'];
+  /**
+   * The page introduction.
+   */
+  intro?: PageHeaderProps['intro'];
+  /**
+   * True if it is homepage. Default: false.
+   */
+  isHome?: LayoutProps['isHome'];
+  /**
+   * The page title.
+   */
+  title: PageHeaderProps['title'];
+  /**
+   * An array of widgets to put in the last sidebar.
+   */
+  widgets?: ReactNode[];
+  /**
+   * Show the table of contents. Default: false.
+   */
+  withToC?: boolean;
+};
+
+/**
+ * PageLayout component
+ *
+ * Render the pages layout.
+ */
+const PageLayout: FC<PageLayoutProps> = ({
+  children,
+  allowComments = false,
+  breadcrumb,
+  comments,
+  footerMeta,
+  headerMeta,
+  intro,
+  isHome = false,
+  widgets,
+  title,
+  withToC = false,
+}) => {
+  const intl = useIntl();
+  const commentsTitle = intl.formatMessage({
+    defaultMessage: 'Comments',
+    description: 'PageLayout: comments title',
+    id: '+dJU3e',
+  });
+  const commentFormTitle = intl.formatMessage({
+    defaultMessage: 'Leave a comment',
+    description: 'PageLayout: comment form title',
+    id: 'kzIYoQ',
+  });
+
+  const bodyRef = useRef<HTMLDivElement>(null);
+  const isMounted = useIsMounted(bodyRef);
+
+  const hasComments = Array.isArray(comments) && comments.length > 0;
+  const hasCommentsSection = hasComments || allowComments;
+  const articleModifier = hasCommentsSection
+    ? 'article--has-comments'
+    : 'article--no-comments';
+
+  const saveComment = () => {
+    return null;
+  };
+
+  return (
+    <Layout
+      isHome={isHome}
+      className={`${styles.article} ${styles[articleModifier]}`}
+    >
+      <Breadcrumb
+        items={breadcrumb}
+        className={styles.breadcrumb}
+        itemClassName={styles.breadcrumb__items}
+      />
+      <PageHeader
+        title={title}
+        intro={intro}
+        meta={headerMeta}
+        className={styles.header}
+      />
+      {withToC && (
+        <Sidebar className={`${styles.sidebar} ${styles['sidebar--first']}`}>
+          {isMounted && bodyRef.current && (
+            <TableOfContents wrapper={bodyRef.current} />
+          )}
+        </Sidebar>
+      )}
+      <div ref={bodyRef} className={styles.body}>
+        {children}
+      </div>
+      <PageFooter meta={footerMeta} className={styles.footer} />
+      <Sidebar className={`${styles.sidebar} ${styles['sidebar--last']}`}>
+        {widgets}
+      </Sidebar>
+      {hasCommentsSection && (
+        <div className={styles.comments}>
+          {hasComments && (
+            <section className={styles.comments__section}>
+              <Heading level={2}>{commentsTitle}</Heading>
+              <CommentsList
+                saveComment={saveComment}
+                comments={comments}
+                depth={2}
+              />
+            </section>
+          )}
+          {allowComments && (
+            <section className={styles.comments__section}>
+              <CommentForm saveComment={saveComment} title={commentFormTitle} />
+            </section>
+          )}
+        </div>
+      )}
+    </Layout>
+  );
+};
+
+export default PageLayout;
diff --git a/src/styles/abstracts/_placeholders.scss b/src/styles/abstracts/_placeholders.scss
index d1c0a7a..18b1c03 100644
--- a/src/styles/abstracts/_placeholders.scss
+++ b/src/styles/abstracts/_placeholders.scss
@@ -1,3 +1,4 @@
 @forward "./placeholders/animations";
 @forward "./placeholders/clearfix";
+@forward "./placeholders/layout";
 @forward "./placeholders/list";
diff --git a/src/styles/abstracts/placeholders/_layout.scss b/src/styles/abstracts/placeholders/_layout.scss
new file mode 100644
index 0000000..1a28acb
--- /dev/null
+++ b/src/styles/abstracts/placeholders/_layout.scss
@@ -0,0 +1,25 @@
+@use "@styles/abstracts/mixins" as mix;
+
+%grid {
+  display: grid;
+  align-items: center;
+  grid-template-columns:
+    minmax(0, 1fr) min(calc(100vw - calc(var(--spacing-md) * 2)), 80ch)
+    var(--column-3, minmax(0, 1fr));
+  column-gap: var(--grid-gap, var(--spacing-md));
+
+  @include mix.media("screen") {
+    @include mix.dimensions("md") {
+      grid-template-columns:
+        minmax(0, 1fr) clamp(60ch, 60vw, 80ch)
+        var(--column-3, minmax(0, 3fr));
+      column-gap: var(--grid-gap, var(--spacing-xl));
+    }
+
+    @include mix.dimensions("lg") {
+      grid-template-columns:
+        minmax(0, 1fr) clamp(47ch, 47vw, 80ch)
+        var(--column-3, minmax(0, 1fr));
+    }
+  }
+}
diff --git a/src/utils/helpers/format.ts b/src/utils/helpers/format.ts
index dd35868..47a7b57 100644
--- a/src/utils/helpers/format.ts
+++ b/src/utils/helpers/format.ts
@@ -14,6 +14,7 @@ import {
   TopicPreview,
   Thematic,
 } from '@ts/types/taxonomies';
+import { settings } from '@utils/config';
 
 /**
  * Format a post preview from RawArticlePreview to ArticlePreview type.
@@ -269,11 +270,14 @@ export const getFormattedPost = (rawPost: RawArticle): Article => {
 
 /**
  * Converts a date to a string by using the specified locale.
- * @param {string} date The date.
- * @param {string} locale A locale.
+ * @param {string} date - The date.
+ * @param {string} [locale] - A locale.
  * @returns {string} The formatted date to locale date string.
  */
-export const getFormattedDate = (date: string, locale: string) => {
+export const getFormattedDate = (
+  date: string,
+  locale: string = settings.locales.defaultLocale
+): string => {
   const dateOptions: Intl.DateTimeFormatOptions = {
     day: 'numeric',
     month: 'long',
@@ -283,6 +287,24 @@ export const getFormattedDate = (date: string, locale: string) => {
   return new Date(date).toLocaleDateString(locale, dateOptions);
 };
 
+/**
+ * Converts a date to a time string by using the specified locale.
+ * @param {string} date - The date.
+ * @param {string} [locale] - A locale.
+ * @returns {string} The formatted time to locale date string.
+ */
+export const getFormattedTime = (
+  date: string,
+  locale: string = settings.locales.defaultLocale
+): string => {
+  const time = new Date(date).toLocaleTimeString(locale, {
+    hour: 'numeric',
+    minute: 'numeric',
+  });
+
+  return locale === 'fr' ? time.replace(':', 'h') : time;
+};
+
 /**
  * Convert an array of slugs to an array of params with slug.
  * @param {Slug} array - An array of object with slug.
diff --git a/src/utils/hooks/use-is-mounted.tsx b/src/utils/hooks/use-is-mounted.tsx
new file mode 100644
index 0000000..ca79afb
--- /dev/null
+++ b/src/utils/hooks/use-is-mounted.tsx
@@ -0,0 +1,19 @@
+import { RefObject, useEffect, useState } from 'react';
+
+/**
+ * Check if an HTML element is mounted.
+ *
+ * @param {RefObject<HTMLElement>} ref - A React reference to an HTML element.
+ * @returns {boolean} True if the HTML element is mounted.
+ */
+const useIsMounted = (ref: RefObject<HTMLElement>) => {
+  const [isMounted, setIsMounted] = useState<boolean>(false);
+
+  useEffect(() => {
+    if (ref.current) setIsMounted(true);
+  }, [ref]);
+
+  return isMounted;
+};
+
+export default useIsMounted;
-- 
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/molecules/layout/meta.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/molecules/layout/meta.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 056ed0d5f050158cebad689099214b164539899a Mon Sep 17 00:00:00 2001
From: Armand Philippot <git@armandphilippot.com>
Date: Sun, 15 May 2022 19:08:58 +0200
Subject: chore: improve accessibility

---
 src/components/atoms/layout/sidebar.stories.tsx    | 13 +++++++++++
 src/components/atoms/layout/sidebar.tsx            |  8 +++++--
 src/components/atoms/links/link.module.scss        | 23 +++++++++++++++++++
 src/components/molecules/layout/meta.tsx           | 26 +++++++++++++++++-----
 src/components/organisms/layout/footer.tsx         | 15 ++++++++++++-
 .../organisms/layout/summary.module.scss           |  5 +++++
 src/components/organisms/layout/summary.tsx        |  3 ++-
 src/components/templates/page/page-layout.tsx      | 18 +++++++++++++--
 src/pages/index.tsx                                |  2 +-
 9 files changed, 100 insertions(+), 13 deletions(-)

(limited to 'src/components/molecules/layout/meta.tsx')

diff --git a/src/components/atoms/layout/sidebar.stories.tsx b/src/components/atoms/layout/sidebar.stories.tsx
index 337d0c9..175af94 100644
--- a/src/components/atoms/layout/sidebar.stories.tsx
+++ b/src/components/atoms/layout/sidebar.stories.tsx
@@ -9,6 +9,19 @@ export default {
   title: 'Atoms/Layout',
   component: SidebarComponent,
   argTypes: {
+    'aria-label': {
+      control: {
+        type: 'text',
+      },
+      description: 'An accessible name for the sidebar.',
+      table: {
+        category: 'Accessibility',
+      },
+      type: {
+        name: 'string',
+        required: false,
+      },
+    },
     children: {
       control: {
         type: 'text',
diff --git a/src/components/atoms/layout/sidebar.tsx b/src/components/atoms/layout/sidebar.tsx
index d13cc0d..d86af37 100644
--- a/src/components/atoms/layout/sidebar.tsx
+++ b/src/components/atoms/layout/sidebar.tsx
@@ -2,6 +2,10 @@ import { FC, ReactNode } from 'react';
 import styles from './sidebar.module.scss';
 
 export type SidebarProps = {
+  /**
+   * An accessible name for the sidebar.
+   */
+  'aria-label'?: string;
   /**
    * The sidebar body.
    */
@@ -17,9 +21,9 @@ export type SidebarProps = {
  *
  * Render an aside element.
  */
-const Sidebar: FC<SidebarProps> = ({ children, className = '' }) => {
+const Sidebar: FC<SidebarProps> = ({ children, className = '', ...props }) => {
   return (
-    <aside className={`${styles.wrapper} ${className}`}>
+    <aside className={`${styles.wrapper} ${className}`} {...props}>
       <div className={styles.body}>{children}</div>
     </aside>
   );
diff --git a/src/components/atoms/links/link.module.scss b/src/components/atoms/links/link.module.scss
index 1b89727..5c97bd2 100644
--- a/src/components/atoms/links/link.module.scss
+++ b/src/components/atoms/links/link.module.scss
@@ -2,6 +2,29 @@
 @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/molecules/layout/meta.tsx b/src/components/molecules/layout/meta.tsx
index 1d5e04b..1f6219a 100644
--- a/src/components/molecules/layout/meta.tsx
+++ b/src/components/molecules/layout/meta.tsx
@@ -13,6 +13,10 @@ export type CustomMeta = {
 };
 
 export type MetaComments = {
+  /**
+   * A page title.
+   */
+  about: string;
   /**
    * The comments count.
    */
@@ -269,19 +273,29 @@ const Meta: FC<MetaProps> = ({
    * @param comments - The comments object.
    * @returns {string | JSX.Element} - The comments count.
    */
-  const getCommentsCount = (comments: MetaComments) => {
-    const { count, target } = comments;
+  const getCommentsCount = (comments: MetaComments): string | JSX.Element => {
+    const { about, count, target } = comments;
     const commentsCount = intl.formatMessage(
       {
         defaultMessage:
-          '{commentsCount, plural, =0 {No comments} one {# comment} other {# comments}}',
-        id: 'adTrj7',
+          '{commentsCount, plural, =0 {No comments} one {# comment} other {# comments}}<a11y> about {title}</a11y>',
+        id: '02rgLO',
         description: 'Meta: comments count',
       },
-      { commentsCount: count }
+      {
+        a11y: (chunks: ReactNode) => (
+          <span className="screen-reader-text">{chunks}</span>
+        ),
+        commentsCount: count,
+        title: about,
+      }
     );
 
-    return target ? <Link href={target}>{commentsCount}</Link> : commentsCount;
+    return target ? (
+      <Link href={target}>{commentsCount as JSX.Element}</Link>
+    ) : (
+      (commentsCount as JSX.Element)
+    );
   };
 
   /**
diff --git a/src/components/organisms/layout/footer.tsx b/src/components/organisms/layout/footer.tsx
index 15bfa24..1426e96 100644
--- a/src/components/organisms/layout/footer.tsx
+++ b/src/components/organisms/layout/footer.tsx
@@ -4,6 +4,7 @@ import Copyright, {
 import BackToTop from '@components/molecules/buttons/back-to-top';
 import Nav, { type NavItem } from '@components/molecules/nav/nav';
 import { FC } from 'react';
+import { useIntl } from 'react-intl';
 import styles from './footer.module.scss';
 
 export type FooterProps = {
@@ -31,6 +32,13 @@ export type FooterProps = {
  * Renders a footer with copyright and nav;
  */
 const Footer: FC<FooterProps> = ({ className, copyright, navItems, topId }) => {
+  const intl = useIntl();
+  const ariaLabel = intl.formatMessage({
+    defaultMessage: 'Footer',
+    description: 'Footer: an accessible name for footer nav',
+    id: 'd4N8nD',
+  });
+
   return (
     <footer className={`${styles.wrapper} ${className}`}>
       <Copyright
@@ -39,7 +47,12 @@ const Footer: FC<FooterProps> = ({ className, copyright, navItems, topId }) => {
         icon={copyright.icon}
       />
       {navItems && (
-        <Nav kind="footer" items={navItems} className={styles.nav} />
+        <Nav
+          aria-label={ariaLabel}
+          kind="footer"
+          items={navItems}
+          className={styles.nav}
+        />
       )}
       <BackToTop target={topId} className={styles['back-to-top']} />
     </footer>
diff --git a/src/components/organisms/layout/summary.module.scss b/src/components/organisms/layout/summary.module.scss
index 9d28bc6..7e86dd2 100644
--- a/src/components/organisms/layout/summary.module.scss
+++ b/src/components/organisms/layout/summary.module.scss
@@ -80,6 +80,11 @@
   }
 }
 
+.link {
+  display: block;
+  width: fit-content;
+}
+
 .title {
   margin: 0;
   background: none;
diff --git a/src/components/organisms/layout/summary.tsx b/src/components/organisms/layout/summary.tsx
index 078f9ee..3831c0c 100644
--- a/src/components/organisms/layout/summary.tsx
+++ b/src/components/organisms/layout/summary.tsx
@@ -116,6 +116,7 @@ const Summary: FC<SummaryProps> = ({
         </Link>
       )),
       comments: {
+        about: title,
         count: commentsCount || 0,
         target: `${url}#comments`,
       },
@@ -126,7 +127,7 @@ const Summary: FC<SummaryProps> = ({
     <article className={styles.wrapper}>
       {cover && <ResponsiveImage className={styles.cover} {...cover} />}
       <header className={styles.header}>
-        <Link href={url}>
+        <Link href={url} className={styles.link}>
           <Heading level={titleLevel} className={styles.title}>
             {title}
           </Heading>
diff --git a/src/components/templates/page/page-layout.tsx b/src/components/templates/page/page-layout.tsx
index 045b8c1..4e4ff00 100644
--- a/src/components/templates/page/page-layout.tsx
+++ b/src/components/templates/page/page-layout.tsx
@@ -185,7 +185,14 @@ const PageLayout: FC<PageLayoutProps> = ({
         className={styles.header}
       />
       {withToC && (
-        <Sidebar className={`${styles.sidebar} ${styles['sidebar--first']}`}>
+        <Sidebar
+          className={`${styles.sidebar} ${styles['sidebar--first']}`}
+          aria-label={intl.formatMessage({
+            defaultMessage: 'Table of contents sidebar',
+            id: 'Q+1GbT',
+            description: 'PageLayout: accessible name for ToC sidebar',
+          })}
+        >
           {isMounted && bodyRef.current && (
             <TableOfContents wrapper={bodyRef.current} />
           )}
@@ -203,7 +210,14 @@ const PageLayout: FC<PageLayoutProps> = ({
         </div>
       )}
       <PageFooter meta={footerMeta} className={styles.footer} />
-      <Sidebar className={`${styles.sidebar} ${styles['sidebar--last']}`}>
+      <Sidebar
+        className={`${styles.sidebar} ${styles['sidebar--last']}`}
+        aria-label={intl.formatMessage({
+          defaultMessage: 'Sidebar',
+          id: 'c556Qo',
+          description: 'PageLayout: accessible name for the sidebar',
+        })}
+      >
         {widgets}
       </Sidebar>
       {hasCommentsSection && (
diff --git a/src/pages/index.tsx b/src/pages/index.tsx
index 6108d2f..bc9b572 100644
--- a/src/pages/index.tsx
+++ b/src/pages/index.tsx
@@ -322,7 +322,7 @@ const HomePage: NextPage<HomeProps> = ({ recentPosts }) => {
   };
 
   return (
-    <Layout>
+    <Layout isHome={true}>
       <Head>
         <title>{pageTitle}</title>
         <meta name="description" content={pageDescription} />
-- 
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/molecules/layout/meta.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

