From 34502bd004c2522a8f2a217da3adf51586d1dec3 Mon Sep 17 00:00:00 2001
From: Armand Philippot <git@armandphilippot.com>
Date: Thu, 21 Apr 2022 17:03:45 +0200
Subject: chore: add a PageHeader component

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

(limited to 'src/components/molecules/layout/page-header.tsx')

diff --git a/src/components/molecules/layout/page-header.tsx b/src/components/molecules/layout/page-header.tsx
new file mode 100644
index 0000000..174e246
--- /dev/null
+++ b/src/components/molecules/layout/page-header.tsx
@@ -0,0 +1,49 @@
+import Heading from '@components/atoms/headings/heading';
+import styles from './page-header.module.scss';
+import Meta, { type MetaMap } from './meta';
+import { FC } from 'react';
+
+export type PageHeaderProps = {
+  /**
+   * Set additional classnames to the header element.
+   */
+  className?: string;
+  /**
+   * The page introduction.
+   */
+  intro?: string;
+  /**
+   * The page metadata.
+   */
+  meta?: MetaMap;
+  /**
+   * The page title.
+   */
+  title: string;
+};
+
+/**
+ * PageHeader component
+ *
+ * Render a header element with page title, meta and intro.
+ */
+const PageHeader: FC<PageHeaderProps> = ({
+  className = '',
+  intro,
+  meta,
+  title,
+}) => {
+  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>}
+      </div>
+    </header>
+  );
+};
+
+export default PageHeader;
-- 
cgit v1.2.3


From 732d0943f8041d76262222a092b014f2557085ef Mon Sep 17 00:00:00 2001
From: Armand Philippot <git@armandphilippot.com>
Date: Mon, 2 May 2022 18:57:29 +0200
Subject: chore: add homepage

---
 src/components/atoms/lists/list.module.scss        |  16 +
 src/components/atoms/lists/list.stories.tsx        |  13 +-
 src/components/atoms/lists/list.tsx                |   4 +-
 src/components/molecules/layout/card.module.scss   |   3 +-
 src/components/molecules/layout/card.tsx           |   2 +-
 src/components/molecules/layout/page-header.tsx    |   2 +-
 src/components/molecules/nav/nav.stories.tsx       |  26 ++
 src/components/molecules/nav/nav.tsx               |   7 +-
 .../organisms/layout/cards-list.module.scss        |   4 +-
 .../organisms/layout/cards-list.stories.tsx        |  13 +
 src/components/organisms/layout/cards-list.tsx     |   8 +-
 src/components/templates/layout/layout.module.scss |   9 -
 src/components/templates/layout/layout.stories.tsx |  10 +-
 src/components/templates/layout/layout.tsx         | 107 ++++--
 src/pages/index.tsx                                | 365 +++++++++++++++++++++
 src/services/graphql/articles.ts                   |  52 ++-
 src/styles/pages/Home.module.scss                  |  49 ---
 src/styles/pages/home.module.scss                  |  36 ++
 src/utils/hooks/use-settings.tsx                   | 112 +++++++
 19 files changed, 745 insertions(+), 93 deletions(-)
 create mode 100644 src/pages/index.tsx
 delete mode 100644 src/styles/pages/Home.module.scss
 create mode 100644 src/styles/pages/home.module.scss
 create mode 100644 src/utils/hooks/use-settings.tsx

(limited to 'src/components/molecules/layout/page-header.tsx')

diff --git a/src/components/atoms/lists/list.module.scss b/src/components/atoms/lists/list.module.scss
index df3b49c..f647072 100644
--- a/src/components/atoms/lists/list.module.scss
+++ b/src/components/atoms/lists/list.module.scss
@@ -1,3 +1,5 @@
+@use "@styles/abstracts/placeholders";
+
 .list {
   margin: 0;
 
@@ -36,4 +38,18 @@
       margin-bottom: var(--spacing-2xs);
     }
   }
+
+  &--flex {
+    @extend %reset-list;
+
+    display: flex;
+    flex-flow: row wrap;
+    gap: var(--spacing-sm);
+  }
+
+  &--flex &--flex {
+    display: initial;
+    position: relative;
+    top: var(--spacing-2xs);
+  }
 }
diff --git a/src/components/atoms/lists/list.stories.tsx b/src/components/atoms/lists/list.stories.tsx
index 3a80962..54fdd3a 100644
--- a/src/components/atoms/lists/list.stories.tsx
+++ b/src/components/atoms/lists/list.stories.tsx
@@ -39,8 +39,8 @@ export default {
       control: {
         type: 'select',
       },
-      description: 'The list kind: ordered or unordered.',
-      options: ['ordered', 'unordered'],
+      description: 'The list kind: flex, ordered or unordered.',
+      options: ['flex', 'ordered', 'unordered'],
       table: {
         category: 'Options',
         defaultValue: { summary: 'unordered' },
@@ -71,6 +71,15 @@ const items: ListItem[] = [
   { id: 'item-4', value: 'Item 4' },
 ];
 
+/**
+ * List Stories - Flex list
+ */
+export const Flex = Template.bind({});
+Flex.args = {
+  items,
+  kind: 'flex',
+};
+
 /**
  * List Stories - Ordered list
  */
diff --git a/src/components/atoms/lists/list.tsx b/src/components/atoms/lists/list.tsx
index 6726802..711ade1 100644
--- a/src/components/atoms/lists/list.tsx
+++ b/src/components/atoms/lists/list.tsx
@@ -30,9 +30,9 @@ export type ListProps = {
    */
   itemsClassName?: string;
   /**
-   * The list kind (ordered or unordered).
+   * The list kind.
    */
-  kind?: 'ordered' | 'unordered';
+  kind?: 'ordered' | 'unordered' | 'flex';
   /**
    * Set margin between list items. Default: true.
    */
diff --git a/src/components/molecules/layout/card.module.scss b/src/components/molecules/layout/card.module.scss
index 85c319a..d5b9836 100644
--- a/src/components/molecules/layout/card.module.scss
+++ b/src/components/molecules/layout/card.module.scss
@@ -19,7 +19,8 @@
 
   .cover {
     align-self: flex-start;
-    max-height: fun.convert-px(150);
+    place-content: center;
+    height: fun.convert-px(150);
     margin: auto;
     border-bottom: fun.convert-px(1) solid var(--color-border);
   }
diff --git a/src/components/molecules/layout/card.tsx b/src/components/molecules/layout/card.tsx
index 15927e9..89f100e 100644
--- a/src/components/molecules/layout/card.tsx
+++ b/src/components/molecules/layout/card.tsx
@@ -93,7 +93,7 @@ const Card: FC<CardProps> = ({
             {title}
           </Heading>
         </header>
-        {tagline && <div className={styles.tagline}>{tagline}</div>}
+        <div className={styles.tagline}>{tagline}</div>
         {meta && (
           <footer className={styles.footer}>
             <DescriptionList
diff --git a/src/components/molecules/layout/page-header.tsx b/src/components/molecules/layout/page-header.tsx
index 174e246..1663085 100644
--- a/src/components/molecules/layout/page-header.tsx
+++ b/src/components/molecules/layout/page-header.tsx
@@ -11,7 +11,7 @@ export type PageHeaderProps = {
   /**
    * The page introduction.
    */
-  intro?: string;
+  intro?: string | JSX.Element;
   /**
    * The page metadata.
    */
diff --git a/src/components/molecules/nav/nav.stories.tsx b/src/components/molecules/nav/nav.stories.tsx
index 25455fd..5cef5f0 100644
--- a/src/components/molecules/nav/nav.stories.tsx
+++ b/src/components/molecules/nav/nav.stories.tsx
@@ -11,6 +11,19 @@ export default {
   title: 'Molecules/Navigation/Nav',
   component: NavComponent,
   argTypes: {
+    'aria-label': {
+      control: {
+        type: 'text',
+      },
+      description: 'An accessible name for the navigation.',
+      table: {
+        category: 'Accessibility',
+      },
+      type: {
+        name: 'string',
+        required: false,
+      },
+    },
     className: {
       control: {
         type: 'text',
@@ -46,6 +59,19 @@ export default {
         required: true,
       },
     },
+    listClassName: {
+      control: {
+        type: 'text',
+      },
+      description: 'Set additional classnames to the navigation list.',
+      table: {
+        category: 'Styles',
+      },
+      type: {
+        name: 'string',
+        required: false,
+      },
+    },
   },
   decorators: [
     (Story) => (
diff --git a/src/components/molecules/nav/nav.tsx b/src/components/molecules/nav/nav.tsx
index 2666ea2..581f813 100644
--- a/src/components/molecules/nav/nav.tsx
+++ b/src/components/molecules/nav/nav.tsx
@@ -23,6 +23,10 @@ export type NavItem = {
 };
 
 export type NavProps = {
+  /**
+   * An accessible name.
+   */
+  'aria-label'?: string;
   /**
    * Set additional classnames to the navigation wrapper.
    */
@@ -51,6 +55,7 @@ const Nav: FC<NavProps> = ({
   items,
   kind,
   listClassName = '',
+  ...props
 }) => {
   const kindClass = `nav--${kind}`;
 
@@ -71,7 +76,7 @@ const Nav: FC<NavProps> = ({
   };
 
   return (
-    <nav className={`${styles[kindClass]} ${className}`}>
+    <nav className={`${styles[kindClass]} ${className}`} {...props}>
       <ul className={`${styles.nav__list} ${listClassName}`}>{getItems()}</ul>
     </nav>
   );
diff --git a/src/components/organisms/layout/cards-list.module.scss b/src/components/organisms/layout/cards-list.module.scss
index 9fe428c..2763585 100644
--- a/src/components/organisms/layout/cards-list.module.scss
+++ b/src/components/organisms/layout/cards-list.module.scss
@@ -1,12 +1,10 @@
 @use "@styles/abstracts/placeholders";
 
 .wrapper {
-  --card-width: 30ch;
-
   display: grid;
   grid-template-columns: repeat(
     auto-fit,
-    min(calc(100vw - (var(--spacing-md) * 2)), var(--card-width))
+    min(calc(100vw - (var(--spacing-md) * 2)), var(--card-width, 30ch))
   );
   gap: var(--spacing-sm);
   place-content: center;
diff --git a/src/components/organisms/layout/cards-list.stories.tsx b/src/components/organisms/layout/cards-list.stories.tsx
index 7ff4365..fe0ebfd 100644
--- a/src/components/organisms/layout/cards-list.stories.tsx
+++ b/src/components/organisms/layout/cards-list.stories.tsx
@@ -12,6 +12,19 @@ export default {
     kind: 'unordered',
   },
   argTypes: {
+    className: {
+      control: {
+        type: 'text',
+      },
+      description: 'Set additional classnames to the list wrapper.',
+      table: {
+        category: 'Styles',
+      },
+      type: {
+        name: 'string',
+        required: false,
+      },
+    },
     coverFit: {
       control: {
         type: 'select',
diff --git a/src/components/organisms/layout/cards-list.tsx b/src/components/organisms/layout/cards-list.tsx
index 33ffe23..1558d7c 100644
--- a/src/components/organisms/layout/cards-list.tsx
+++ b/src/components/organisms/layout/cards-list.tsx
@@ -14,6 +14,10 @@ export type CardsListItem = Omit<
 };
 
 export type CardsListProps = {
+  /**
+   * Set additional classnames to the list wrapper.
+   */
+  className?: string;
   /**
    * The cover fit.
    */
@@ -38,6 +42,7 @@ export type CardsListProps = {
  * Return a list of Card components.
  */
 const CardsList: FC<CardsListProps> = ({
+  className = '',
   coverFit,
   items,
   kind = 'unordered',
@@ -70,9 +75,10 @@ const CardsList: FC<CardsListProps> = ({
 
   return (
     <List
+      kind="flex"
       items={getCards(items)}
       withMargin={false}
-      className={`${styles.wrapper} ${styles[kindModifier]}`}
+      className={`${styles.wrapper} ${styles[kindModifier]} ${className}`}
     />
   );
 };
diff --git a/src/components/templates/layout/layout.module.scss b/src/components/templates/layout/layout.module.scss
index 3533257..806d2d7 100644
--- a/src/components/templates/layout/layout.module.scss
+++ b/src/components/templates/layout/layout.module.scss
@@ -1,15 +1,6 @@
 @use "@styles/abstracts/functions" as fun;
 @use "@styles/abstracts/mixins" as mix;
 
-:global {
-  #__next {
-    flex: 1;
-    display: flex;
-    flex-flow: column nowrap;
-    min-height: 100vh;
-  }
-}
-
 .header {
   border-bottom: fun.convert-px(3) solid var(--color-border-light);
 }
diff --git a/src/components/templates/layout/layout.stories.tsx b/src/components/templates/layout/layout.stories.tsx
index f3579e3..2415412 100644
--- a/src/components/templates/layout/layout.stories.tsx
+++ b/src/components/templates/layout/layout.stories.tsx
@@ -36,7 +36,15 @@ export default {
   decorators: [
     (Story) => (
       <IntlProvider locale="en">
-        <div id="__next">
+        <div
+          id="__next"
+          style={{
+            flex: 1,
+            display: 'flex',
+            flexFlow: 'column nowrap',
+            minHeight: '100vh',
+          }}
+        >
           <Story />
         </div>
       </IntlProvider>
diff --git a/src/components/templates/layout/layout.tsx b/src/components/templates/layout/layout.tsx
index 601ced4..e1be1af 100644
--- a/src/components/templates/layout/layout.tsx
+++ b/src/components/templates/layout/layout.tsx
@@ -1,4 +1,3 @@
-import photo from '@assets/images/armand-philippot.jpg';
 import ButtonLink from '@components/atoms/buttons/button-link';
 import Career from '@components/atoms/icons/career';
 import CCBySA from '@components/atoms/icons/cc-by-sa';
@@ -8,13 +7,19 @@ import Home from '@components/atoms/icons/home';
 import PostsStack from '@components/atoms/icons/posts-stack';
 import Main from '@components/atoms/layout/main';
 import NoScript from '@components/atoms/layout/no-script';
-import Footer from '@components/organisms/layout/footer';
-import Header, { HeaderProps } from '@components/organisms/layout/header';
-import { settings } from '@utils/config';
+import Footer, { FooterProps } from '@components/organisms/layout/footer';
+import Header, { type HeaderProps } from '@components/organisms/layout/header';
+import useSettings from '@utils/hooks/use-settings';
+import Script from 'next/script';
 import { FC, ReactNode } from 'react';
 import { useIntl } from 'react-intl';
+import { Person, SearchAction, WebSite, WithContext } from 'schema-dts';
 import styles from './layout.module.scss';
 
+export type QueryAction = SearchAction & {
+  'query-input': string;
+};
+
 export type LayoutProps = Pick<HeaderProps, 'isHome'> & {
   /**
    * The layout main content.
@@ -33,6 +38,9 @@ export type LayoutProps = Pick<HeaderProps, 'isHome'> & {
  */
 const Layout: FC<LayoutProps> = ({ children, isHome, ...props }) => {
   const intl = useIntl();
+  const { website } = useSettings();
+  const { baseline, copyright, locales, name, picture, url } = website;
+
   const skipToContent = intl.formatMessage({
     defaultMessage: 'Skip to content',
     description: 'Layout: Skip to content link',
@@ -45,12 +53,12 @@ const Layout: FC<LayoutProps> = ({ children, isHome, ...props }) => {
     id: '7jVUT6',
   });
 
-  const copyright = {
+  const copyrightData = {
     dates: {
-      start: settings.copyright.startYear,
-      end: settings.copyright.endYear,
+      start: copyright.start,
+      end: copyright.end,
     },
-    owner: settings.name,
+    owner: name,
     icon: <CCBySA />,
   };
 
@@ -80,21 +88,77 @@ const Layout: FC<LayoutProps> = ({ children, isHome, ...props }) => {
     id: 'AE4kCD',
   });
 
-  const nav: HeaderProps['nav'] = [
-    { id: 'home', label: homeLabel, href: '#', logo: <Home /> },
-    { id: 'blog', label: blogLabel, href: '#', logo: <PostsStack /> },
+  const mainNav: HeaderProps['nav'] = [
+    { id: 'home', label: homeLabel, href: '/', logo: <Home /> },
+    { id: 'blog', label: blogLabel, href: '/blog', logo: <PostsStack /> },
     {
       id: 'projects',
       label: projectsLabel,
-      href: '#',
+      href: '/projets',
       logo: <ComputerScreen />,
     },
-    { id: 'cv', label: cvLabel, href: '#', logo: <Career /> },
-    { id: 'contact', label: contactLabel, href: '#', logo: <Envelop /> },
+    { id: 'cv', label: cvLabel, href: '/cv', logo: <Career /> },
+    { id: 'contact', label: contactLabel, href: '/contact', logo: <Envelop /> },
+  ];
+
+  const legalNoticeLabel = intl.formatMessage({
+    defaultMessage: 'Legal notice',
+    description: 'Layout: Legal notice label',
+    id: 'nwbzKm',
+  });
+
+  const footerNav: FooterProps['navItems'] = [
+    { id: 'legal-notice', label: legalNoticeLabel, href: '/mentions-legales' },
   ];
 
+  const searchActionSchema: QueryAction = {
+    '@type': 'SearchAction',
+    target: {
+      '@type': 'EntryPoint',
+      urlTemplate: `${url}/recherche?s={search_term_string}`,
+    },
+    query: 'required',
+    'query-input': 'required name=search_term_string',
+  };
+
+  const schemaJsonLd: WithContext<WebSite> = {
+    '@context': 'https://schema.org',
+    '@id': `${url}`,
+    '@type': 'WebSite',
+    name: name,
+    description: baseline,
+    url: url,
+    author: { '@id': `${url}/#branding` },
+    copyrightYear: Number(copyright.start),
+    creator: { '@id': `${url}/#branding` },
+    editor: { '@id': `${url}/#branding` },
+    inLanguage: locales.default,
+    potentialAction: searchActionSchema,
+  };
+
+  const brandingSchema: WithContext<Person> = {
+    '@context': 'https://schema.org',
+    '@type': 'Person',
+    '@id': `${url}/#branding`,
+    name: name,
+    url: url,
+    jobTitle: baseline,
+    image: picture.src,
+    subjectOf: { '@id': `${url}` },
+  };
+
   return (
     <>
+      <Script
+        id="schema-layout"
+        type="application/ld+json"
+        dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }}
+      ></Script>
+      <Script
+        id="schema-branding"
+        type="application/ld+json"
+        dangerouslySetInnerHTML={{ __html: JSON.stringify(brandingSchema) }}
+      />
       <noscript>
         <div className={styles['noscript-spacing']}></div>
       </noscript>
@@ -103,10 +167,10 @@ const Layout: FC<LayoutProps> = ({ children, isHome, ...props }) => {
         {skipToContent}
       </ButtonLink>
       <Header
-        title={settings.name}
-        baseline={settings.baseline.fr}
-        photo={photo.src}
-        nav={nav}
+        title={name}
+        baseline={baseline}
+        photo={picture}
+        nav={mainNav}
         isHome={isHome}
         className={styles.header}
         withLink={true}
@@ -114,7 +178,12 @@ const Layout: FC<LayoutProps> = ({ children, isHome, ...props }) => {
       <Main id="main" className={styles.main}>
         <article {...props}>{children}</article>
       </Main>
-      <Footer copyright={copyright} topId="top" className={styles.footer} />
+      <Footer
+        copyright={copyrightData}
+        navItems={footerNav}
+        topId="top"
+        className={styles.footer}
+      />
       <noscript>
         <NoScript message={noScript} position="top" />
       </noscript>
diff --git a/src/pages/index.tsx b/src/pages/index.tsx
new file mode 100644
index 0000000..c965320
--- /dev/null
+++ b/src/pages/index.tsx
@@ -0,0 +1,365 @@
+import FeedIcon from '@assets/images/icon-feed.svg';
+import ButtonLink from '@components/atoms/buttons/button-link';
+import Envelop from '@components/atoms/icons/envelop';
+import Column, { type ColumnProps } from '@components/atoms/layout/column';
+import Section, { type SectionProps } from '@components/atoms/layout/section';
+import List, { type ListItem } from '@components/atoms/lists/list';
+import ResponsiveImage, {
+  type ResponsiveImageProps,
+} from '@components/molecules/images/responsive-image';
+import Columns, {
+  type ColumnsProps,
+} from '@components/molecules/layout/columns';
+import CardsList, {
+  type CardsListItem,
+} from '@components/organisms/layout/cards-list';
+import Layout from '@components/templates/layout/layout';
+import HomePageContent from '@content/pages/homepage.mdx';
+import { getArticlesCard } from '@services/graphql/articles';
+import styles from '@styles/pages/home.module.scss';
+import { ArticleCard } from '@ts/types/app';
+import { loadTranslation, type Messages } from '@utils/helpers/i18n';
+import useSettings from '@utils/hooks/use-settings';
+import { NestedMDXComponents } from 'mdx/types';
+import { GetStaticProps, NextPage } from 'next';
+import Head from 'next/head';
+import Script from 'next/script';
+import { ReactElement } from 'react';
+import { useIntl } from 'react-intl';
+import { Graph, WebPage } from 'schema-dts';
+
+type HomeProps = {
+  recentPosts: ArticleCard[];
+  translation?: Messages;
+};
+
+/**
+ * Home page.
+ */
+const HomePage: NextPage<HomeProps> = ({ recentPosts }) => {
+  const intl = useIntl();
+
+  /**
+   * Retrieve a list of coding links.
+   *
+   * @returns {JSX.Element} - A list of links.
+   */
+  const CodingLinks = (): JSX.Element => {
+    const links: ListItem[] = [
+      {
+        id: 'web-development',
+        value: (
+          <ButtonLink target="/thematique/developpement-web">
+            {intl.formatMessage({
+              defaultMessage: 'Web development',
+              description: 'HomePage: link to web development thematic',
+              id: 'vkF/RP',
+            })}
+          </ButtonLink>
+        ),
+      },
+      {
+        id: 'projects',
+        value: (
+          <ButtonLink target="/projets">
+            {intl.formatMessage({
+              defaultMessage: 'Projects',
+              description: 'HomePage: link to projects',
+              id: 'N44SOc',
+            })}
+          </ButtonLink>
+        ),
+      },
+    ];
+
+    return <List kind="flex" items={links} className={styles.list} />;
+  };
+
+  /**
+   * Retrieve a list of Coldark repositories.
+   *
+   * @returns {JSX.Element} - A list of links.
+   */
+  const ColdarkRepos = (): JSX.Element => {
+    const links: ListItem[] = [
+      {
+        id: 'coldark-github',
+        value: (
+          <ButtonLink
+            target="https://github.com/ArmandPhilippot/coldark"
+            external={true}
+          >
+            {intl.formatMessage({
+              defaultMessage: 'Github',
+              description: 'HomePage: Github link',
+              id: '3f3PzH',
+            })}
+          </ButtonLink>
+        ),
+      },
+      {
+        id: 'coldark-gitlab',
+        value: (
+          <ButtonLink
+            target="https://gitlab.com/ArmandPhilippot/coldark"
+            external={true}
+          >
+            {intl.formatMessage({
+              defaultMessage: 'Gitlab',
+              description: 'HomePage: Gitlab link',
+              id: '7AnwZ7',
+            })}
+          </ButtonLink>
+        ),
+      },
+    ];
+
+    return <List kind="flex" items={links} className={styles.list} />;
+  };
+
+  /**
+   * Retrieve a list of links related to Free thematic.
+   *
+   * @returns {JSX.Element} - A list of links.
+   */
+  const LibreLinks = (): JSX.Element => {
+    const links: ListItem[] = [
+      {
+        id: 'free',
+        value: (
+          <ButtonLink target="/thematique/libre">
+            {intl.formatMessage({
+              defaultMessage: 'Free',
+              description: 'HomePage: link to free thematic',
+              id: 'w8GrOf',
+            })}
+          </ButtonLink>
+        ),
+      },
+      {
+        id: 'linux',
+        value: (
+          <ButtonLink target="/thematique/linux">
+            {intl.formatMessage({
+              defaultMessage: 'Linux',
+              description: 'HomePage: link to Linux thematic',
+              id: 'jASD7k',
+            })}
+          </ButtonLink>
+        ),
+      },
+    ];
+
+    return <List kind="flex" items={links} className={styles.list} />;
+  };
+
+  /**
+   * Retrieve the Shaarli link.
+   *
+   * @returns {JSX.Element} - A list of links
+   */
+  const ShaarliLink = (): JSX.Element => {
+    const links: ListItem[] = [
+      {
+        id: 'shaarli',
+        value: (
+          <ButtonLink target="https://shaarli.armandphilippot.com/">
+            {intl.formatMessage({
+              defaultMessage: 'Shaarli',
+              description: 'HomePage: link to Shaarli',
+              id: 'i5L19t',
+            })}
+          </ButtonLink>
+        ),
+      },
+    ];
+
+    return <List kind="flex" items={links} className={styles.list} />;
+  };
+
+  /**
+   * Retrieve the additional links.
+   *
+   * @returns {JSX.Element} - A list of links.
+   */
+  const MoreLinks = (): JSX.Element => {
+    const links: ListItem[] = [
+      {
+        id: 'contact-me',
+        value: (
+          <ButtonLink target="/contact">
+            <Envelop className={styles.icon} />
+            {intl.formatMessage({
+              defaultMessage: 'Contact me',
+              description: 'HomePage: contact button text',
+              id: 'sO/Iwj',
+            })}
+          </ButtonLink>
+        ),
+      },
+      {
+        id: 'rss-feed',
+        value: (
+          <ButtonLink target="/feed">
+            <FeedIcon className={`${styles.icon} ${styles['icon--feed']}`} />
+            {intl.formatMessage({
+              defaultMessage: 'Subscribe',
+              description: 'HomePage: RSS feed subscription text',
+              id: 'T4YA64',
+            })}
+          </ButtonLink>
+        ),
+      },
+    ];
+
+    return <List kind="flex" items={links} className={styles.list} />;
+  };
+
+  /**
+   * Get a cards list of recent posts.
+   *
+   * @returns {JSX.Element} - The cards list.
+   */
+  const getRecentPosts = (): JSX.Element => {
+    const posts: CardsListItem[] = recentPosts.map((post) => {
+      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],
+          },
+        ],
+        title: post.title,
+        url: `/article/${post.slug}`,
+      };
+    });
+
+    return (
+      <CardsList
+        items={posts}
+        titleLevel={3}
+        className={`${styles.list} ${styles['list--cards']}`}
+      />
+    );
+  };
+
+  /**
+   * Create the page sections.
+   *
+   * @param {object} obj - An object containing the section body.
+   * @param {ReactElement[]} obj.children - The section body.
+   * @returns {JSX.Element} A section element.
+   */
+  const getSection = ({
+    children,
+    variant,
+  }: {
+    children: ReactElement[];
+    variant: SectionProps['variant'];
+  }): JSX.Element => {
+    const [headingEl, ...content] = children;
+    const title = headingEl.props.children;
+
+    return (
+      <Section
+        title={title}
+        content={content}
+        variant={variant}
+        className={styles.section}
+      />
+    );
+  };
+
+  const components: NestedMDXComponents = {
+    CodingLinks: CodingLinks,
+    ColdarkRepos: ColdarkRepos,
+    Column: (props: ColumnProps) => <Column {...props} />,
+    Columns: (props: ColumnsProps) => (
+      <Columns className={styles.columns} {...props} />
+    ),
+    Image: (props: ResponsiveImageProps) => <ResponsiveImage {...props} />,
+    LibreLinks: LibreLinks,
+    MoreLinks: MoreLinks,
+    RecentPosts: getRecentPosts,
+    Section: getSection,
+    ShaarliLink: ShaarliLink,
+  };
+
+  const { website } = useSettings();
+
+  const pageTitle = intl.formatMessage(
+    {
+      defaultMessage: '{websiteName} | Front-end developer: WordPress/React',
+      description: 'HomePage: SEO - Page title',
+      id: 'PXp2hv',
+    },
+    { websiteName: website.name }
+  );
+  const pageDescription = intl.formatMessage(
+    {
+      defaultMessage:
+        '{websiteName} is a front-end developer located in France. He codes and he writes mostly about web development and open-source.',
+      description: 'HomePage: SEO - Meta description',
+      id: 'tMuNTy',
+    },
+    { websiteName: website.name }
+  );
+
+  const webpageSchema: WebPage = {
+    '@id': `${website.url}/#home`,
+    '@type': 'WebPage',
+    name: pageTitle,
+    description: pageDescription,
+    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',
+    reviewedBy: { '@id': `${website.url}/#branding` },
+    url: `${website.url}`,
+  };
+
+  const schemaJsonLd: Graph = {
+    '@context': 'https://schema.org',
+    '@graph': [webpageSchema],
+  };
+
+  return (
+    <Layout>
+      <Head>
+        <title>{pageTitle}</title>
+        <meta name="description" content={pageDescription} />
+        <meta property="og:url" content={website.url} />
+        <meta property="og:title" content={pageTitle} />
+        <meta property="og:description" content={pageDescription} />
+      </Head>
+      <Script
+        id="schema-homepage"
+        type="application/ld+json"
+        dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }}
+      />
+      <HomePageContent components={components} />
+    </Layout>
+  );
+};
+
+export const getStaticProps: GetStaticProps = async ({ locale }) => {
+  const translation = await loadTranslation(locale);
+  const recentPosts = await getArticlesCard({ first: 3 });
+
+  return {
+    props: {
+      recentPosts,
+      translation,
+    },
+  };
+};
+
+export default HomePage;
diff --git a/src/services/graphql/articles.ts b/src/services/graphql/articles.ts
index e5ce7a5..7aff3e0 100644
--- a/src/services/graphql/articles.ts
+++ b/src/services/graphql/articles.ts
@@ -1,10 +1,19 @@
-import { Article } from '@ts/types/app';
-import { RawArticle, TotalItems } from '@ts/types/raw-data';
+import { type Article, type ArticleCard } from '@ts/types/app';
+import {
+  type RawArticle,
+  type RawArticlePreview,
+  type TotalItems,
+} from '@ts/types/raw-data';
 import { getAuthorFromRawData } from '@utils/helpers/author';
+import { getDates } from '@utils/helpers/dates';
 import { getImageFromRawData } from '@utils/helpers/images';
 import { getPageLinkFromRawData } from '@utils/helpers/pages';
 import { EdgesVars, fetchAPI, getAPIUrl, PageInfo } from './api';
-import { articlesQuery, totalArticlesQuery } from './articles.query';
+import {
+  articlesCardQuery,
+  articlesQuery,
+  totalArticlesQuery,
+} from './articles.query';
 
 /**
  * Retrieve the total number of articles.
@@ -102,3 +111,40 @@ export const getArticles = async ({
     pageInfo: response.posts.pageInfo,
   };
 };
+
+/**
+ * Convert a raw article preview to an article card.
+ *
+ * @param {RawArticlePreview} data - A raw article preview.
+ * @returns {ArticleCard} An article card.
+ */
+const getArticleCardFromRawData = (data: RawArticlePreview): ArticleCard => {
+  const { databaseId, date, featuredImage, slug, title } = data;
+
+  return {
+    cover: featuredImage ? getImageFromRawData(featuredImage.node) : undefined,
+    dates: getDates(date, ''),
+    id: databaseId,
+    slug,
+    title,
+  };
+};
+
+/**
+ * Retrieve the given number of article cards from API.
+ *
+ * @param {EdgesVars} obj - An object.
+ * @param {number} obj.first - The number of articles.
+ * @returns {Promise<ArticleCard[]>} - The article cards data.
+ */
+export const getArticlesCard = async ({
+  first,
+}: EdgesVars): Promise<ArticleCard[]> => {
+  const response = await fetchAPI<RawArticlePreview, typeof articlesCardQuery>({
+    api: getAPIUrl(),
+    query: articlesCardQuery,
+    variables: { first },
+  });
+
+  return response.posts.nodes.map((node) => getArticleCardFromRawData(node));
+};
diff --git a/src/styles/pages/Home.module.scss b/src/styles/pages/Home.module.scss
deleted file mode 100644
index 8225a57..0000000
--- a/src/styles/pages/Home.module.scss
+++ /dev/null
@@ -1,49 +0,0 @@
-@use "@styles/abstracts/functions" as fun;
-@use "@styles/abstracts/placeholders";
-
-.links-list {
-  @extend %flex-list;
-
-  gap: var(--spacing-md);
-  margin: 0 0 var(--spacing-md);
-}
-
-.icon--feed {
-  width: fun.convert-px(20);
-}
-
-:global {
-  [data-theme="dark"] {
-    :local {
-      .icon--feed {
-        filter: brightness(0.8) contrast(1.1);
-      }
-    }
-  }
-}
-
-.section {
-  --icon-size: #{fun.convert-px(20)};
-
-  composes: grid from "@styles/layout/_grid.scss";
-  padding: var(--spacing-md) 0;
-  background: var(--color-bg-secondary);
-
-  &:not(:last-child) {
-    border-bottom: fun.convert-px(1) solid var(--color-border);
-  }
-
-  &:nth-child(2n) {
-    background: var(--color-bg);
-  }
-
-  > * {
-    grid-column: 2;
-  }
-
-  :global {
-    .wp-block-columns {
-      margin: 0 0 var(--spacing-md);
-    }
-  }
-}
diff --git a/src/styles/pages/home.module.scss b/src/styles/pages/home.module.scss
new file mode 100644
index 0000000..873a5a9
--- /dev/null
+++ b/src/styles/pages/home.module.scss
@@ -0,0 +1,36 @@
+@use "@styles/abstracts/functions" as fun;
+@use "@styles/abstracts/mixins" as mix;
+
+.section {
+  --card-width: 25ch;
+
+  &:last-of-type {
+    border-bottom: none;
+  }
+}
+
+.columns {
+  margin: 0 0 var(--spacing-sm);
+}
+
+.list {
+  margin: 0 0 var(--spacing-sm);
+
+  &--cards {
+    @include mix.media("screen") {
+      @include mix.dimensions("md") {
+        margin: 0 calc(var(--spacing-sm) * -1) var(--spacing-sm);
+      }
+    }
+  }
+}
+
+.icon {
+  --icon-size: #{fun.convert-px(20)};
+
+  margin-right: var(--spacing-2xs);
+
+  &--feed {
+    width: var(--icon-size);
+  }
+}
diff --git a/src/utils/hooks/use-settings.tsx b/src/utils/hooks/use-settings.tsx
new file mode 100644
index 0000000..a45e934
--- /dev/null
+++ b/src/utils/hooks/use-settings.tsx
@@ -0,0 +1,112 @@
+import photo from '@assets/images/armand-philippot.jpg';
+import { settings } from '@utils/config';
+import { useRouter } from 'next/router';
+
+export type BlogSettings = {
+  /**
+   * The number of posts per page.
+   */
+  postsPerPage: number;
+};
+
+export type CopyrightSettings = {
+  /**
+   * The copyright end year.
+   */
+  end: string;
+  /**
+   * The copyright start year.
+   */
+  start: string;
+};
+
+export type LocaleSettings = {
+  /**
+   * The default locale.
+   */
+  default: string;
+  /**
+   * The supported locales.
+   */
+  supported: string[];
+};
+
+export type PictureSettings = {
+  /**
+   * The picture height.
+   */
+  height: number;
+  /**
+   * The picture url.
+   */
+  src: string;
+  /**
+   * The picture width.
+   */
+  width: number;
+};
+
+export type WebsiteSettings = {
+  /**
+   * The website name.
+   */
+  name: string;
+  /**
+   * The website baseline.
+   */
+  baseline: string;
+  /**
+   * The website copyright dates.
+   */
+  copyright: CopyrightSettings;
+  /**
+   * The website locales.
+   */
+  locales: LocaleSettings;
+  /**
+   * A picture representing the website.
+   */
+  picture: PictureSettings;
+  /**
+   * The website url.
+   */
+  url: string;
+};
+
+export type UseSettingsReturn = {
+  blog: BlogSettings;
+  website: WebsiteSettings;
+};
+
+/**
+ * Retrieve the website and blog settings.
+ *
+ * @returns {UseSettingsReturn} - An object describing settings.
+ */
+const useSettings = (): UseSettingsReturn => {
+  const { baseline, copyright, locales, name, postsPerPage, url } = settings;
+  const router = useRouter();
+  const locale = router.locale || locales.defaultLocale;
+
+  return {
+    blog: {
+      postsPerPage,
+    },
+    website: {
+      baseline: locale.startsWith('en') ? baseline.en : baseline.fr,
+      copyright: {
+        end: copyright.endYear,
+        start: copyright.startYear,
+      },
+      locales: {
+        default: locales.defaultLocale,
+        supported: locales.supported,
+      },
+      name,
+      picture: photo,
+      url,
+    },
+  };
+};
+
+export default useSettings;
-- 
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/page-header.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 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/page-header.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

