From ec54c7692d5588addcd7563626df381aa86d5d79 Mon Sep 17 00:00:00 2001
From: Armand Philippot <git@armandphilippot.com>
Date: Wed, 20 Apr 2022 19:27:46 +0200
Subject: chore: add a Sidebar component

---
 src/components/atoms/layout/sidebar.tsx | 24 ++++++++++++++++++++++++
 1 file changed, 24 insertions(+)
 create mode 100644 src/components/atoms/layout/sidebar.tsx

(limited to 'src/components/atoms/layout/sidebar.tsx')

diff --git a/src/components/atoms/layout/sidebar.tsx b/src/components/atoms/layout/sidebar.tsx
new file mode 100644
index 0000000..194ed9f
--- /dev/null
+++ b/src/components/atoms/layout/sidebar.tsx
@@ -0,0 +1,24 @@
+import { FC, ReactNode } from 'react';
+import styles from './sidebar.module.scss';
+
+export type SidebarProps = {
+  /**
+   * The sidebar body.
+   */
+  children: ReactNode;
+  /**
+   * Set additional classnames to the aside element.
+   */
+  className?: string;
+};
+
+/**
+ * Sidebar component
+ *
+ * Render an aside element.
+ */
+const Sidebar: FC<SidebarProps> = ({ children, className = '' }) => {
+  return <aside className={`${styles.wrapper} ${className}`}>{children}</aside>;
+};
+
+export default Sidebar;
-- 
cgit v1.2.3


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

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

(limited to 'src/components/atoms/layout/sidebar.tsx')

diff --git a/src/components/atoms/layout/sidebar.module.scss b/src/components/atoms/layout/sidebar.module.scss
index da2acbe..5d36f18 100644
--- a/src/components/atoms/layout/sidebar.module.scss
+++ b/src/components/atoms/layout/sidebar.module.scss
@@ -5,3 +5,8 @@
     margin-top: fun.convert-px(-2);
   }
 }
+
+.body {
+  position: sticky;
+  top: var(--spacing-xs);
+}
diff --git a/src/components/atoms/layout/sidebar.tsx b/src/components/atoms/layout/sidebar.tsx
index 194ed9f..d13cc0d 100644
--- a/src/components/atoms/layout/sidebar.tsx
+++ b/src/components/atoms/layout/sidebar.tsx
@@ -18,7 +18,11 @@ export type SidebarProps = {
  * Render an aside element.
  */
 const Sidebar: FC<SidebarProps> = ({ children, className = '' }) => {
-  return <aside className={`${styles.wrapper} ${className}`}>{children}</aside>;
+  return (
+    <aside className={`${styles.wrapper} ${className}`}>
+      <div className={styles.body}>{children}</div>
+    </aside>
+  );
 };
 
 export default Sidebar;
diff --git a/src/components/molecules/layout/meta.module.scss b/src/components/molecules/layout/meta.module.scss
new file mode 100644
index 0000000..f7cc55b
--- /dev/null
+++ b/src/components/molecules/layout/meta.module.scss
@@ -0,0 +1,17 @@
+@use "@styles/abstracts/mixins" as mix;
+
+.list {
+  display: grid;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+
+  @include mix.media("screen") {
+    @include mix.dimensions("sm") {
+      display: flex;
+      flex-flow: column nowrap;
+    }
+  }
+}
+
+.value {
+  word-break: break-all;
+}
diff --git a/src/components/molecules/layout/meta.tsx b/src/components/molecules/layout/meta.tsx
index fcce473..d05396e 100644
--- a/src/components/molecules/layout/meta.tsx
+++ b/src/components/molecules/layout/meta.tsx
@@ -3,6 +3,7 @@ import DescriptionList, {
   type DescriptionListItem,
 } from '@components/atoms/lists/description-list';
 import { FC, ReactNode } from 'react';
+import styles from './meta.module.scss';
 
 export type MetaItem = {
   /**
@@ -23,7 +24,7 @@ export type MetaProps = {
   /**
    * Set additional classnames to the meta wrapper.
    */
-  className?: string;
+  className?: DescriptionListProps['className'];
   /**
    * The meta data.
    */
@@ -43,7 +44,7 @@ export type MetaProps = {
  *
  * Renders the page metadata.
  */
-const Meta: FC<MetaProps> = ({ data, ...props }) => {
+const Meta: FC<MetaProps> = ({ className, data, ...props }) => {
   /**
    * Transform the metadata to description list item format.
    *
@@ -68,7 +69,14 @@ const Meta: FC<MetaProps> = ({ data, ...props }) => {
     return listItems;
   };
 
-  return <DescriptionList items={getItems(data)} {...props} />;
+  return (
+    <DescriptionList
+      items={getItems(data)}
+      className={`${styles.list} ${className}`}
+      descriptionClassName={styles.value}
+      {...props}
+    />
+  );
 };
 
 export default Meta;
diff --git a/src/components/molecules/nav/breadcrumb.stories.tsx b/src/components/molecules/nav/breadcrumb.stories.tsx
index 500ae6c..e26b480 100644
--- a/src/components/molecules/nav/breadcrumb.stories.tsx
+++ b/src/components/molecules/nav/breadcrumb.stories.tsx
@@ -22,6 +22,19 @@ export default {
         required: false,
       },
     },
+    itemClassName: {
+      control: {
+        type: 'text',
+      },
+      table: {
+        category: 'Styles',
+      },
+      description: 'Set additional classnames to the breadcrumb items.',
+      type: {
+        name: 'string',
+        required: false,
+      },
+    },
     items: {
       description: 'The breadcrumb items.',
       type: {
diff --git a/src/components/molecules/nav/breadcrumb.tsx b/src/components/molecules/nav/breadcrumb.tsx
index 6dc86a0..d184d65 100644
--- a/src/components/molecules/nav/breadcrumb.tsx
+++ b/src/components/molecules/nav/breadcrumb.tsx
@@ -26,6 +26,10 @@ export type BreadcrumbProps = {
    * Set additional classnames to the nav element.
    */
   className?: string;
+  /**
+   * Set additional classnames to the breadcrumb items.
+   */
+  itemClassName?: string;
   /**
    * The breadcrumb items
    */
@@ -37,9 +41,19 @@ export type BreadcrumbProps = {
  *
  * Render a breadcrumb navigation.
  */
-const Breadcrumb: FC<BreadcrumbProps> = ({ items, ...props }) => {
+const Breadcrumb: FC<BreadcrumbProps> = ({
+  itemClassName = '',
+  items,
+  ...props
+}) => {
   const intl = useIntl();
 
+  const ariaLabel = intl.formatMessage({
+    defaultMessage: 'Breadcrumb',
+    description: 'Breadcrumb: an accessible name for the breadcrumb nav.',
+    id: '28nnDY',
+  });
+
   /**
    * Retrieve the breadcrumb list items.
    *
@@ -49,12 +63,12 @@ const Breadcrumb: FC<BreadcrumbProps> = ({ items, ...props }) => {
   const getListItems = (list: BreadcrumbItem[]): JSX.Element[] => {
     return list.map((item, index) => {
       const isLastItem = index === list.length - 1;
-      const itemClassnames = isLastItem
+      const itemStyles = isLastItem
         ? `${styles.item} screen-reader-text`
         : styles.item;
 
       return (
-        <li key={item.id} className={itemClassnames}>
+        <li key={item.id} className={`${itemStyles} ${itemClassName}`}>
           {isLastItem ? item.name : <Link href={item.url}>{item.name}</Link>}
         </li>
       );
@@ -96,7 +110,7 @@ const Breadcrumb: FC<BreadcrumbProps> = ({ items, ...props }) => {
         type="application/ld+json"
         dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }}
       />
-      <nav {...props}>
+      <nav aria-label={ariaLabel} {...props}>
         <span className="screen-reader-text">
           {intl.formatMessage({
             defaultMessage: 'You are here:',
diff --git a/src/components/organisms/layout/comment.stories.tsx b/src/components/organisms/layout/comment.stories.tsx
index b14621b..3794b06 100644
--- a/src/components/organisms/layout/comment.stories.tsx
+++ b/src/components/organisms/layout/comment.stories.tsx
@@ -51,16 +51,6 @@ export default {
         required: true,
       },
     },
-    postId: {
-      control: {
-        type: 'number',
-      },
-      description: 'The post id.',
-      type: {
-        name: 'number',
-        required: true,
-      },
-    },
     publication: {
       description: 'The publication date.',
       type: {
diff --git a/src/components/organisms/layout/header.module.scss b/src/components/organisms/layout/header.module.scss
index 7ae683f..a98cf45 100644
--- a/src/components/organisms/layout/header.module.scss
+++ b/src/components/organisms/layout/header.module.scss
@@ -7,7 +7,7 @@
     minmax(0, 1fr) min(calc(100vw - calc(var(--spacing-md) * 2)), 100ch)
     minmax(0, 1fr);
   align-items: center;
-  padding: var(--spacing-sm) 0 var(--spacing-md);
+  padding: var(--spacing-md) 0 var(--spacing-lg);
 
   .toolbar {
     justify-content: space-around;
diff --git a/src/components/organisms/layout/posts-list.module.scss b/src/components/organisms/layout/posts-list.module.scss
index 4d80442..f072082 100644
--- a/src/components/organisms/layout/posts-list.module.scss
+++ b/src/components/organisms/layout/posts-list.module.scss
@@ -1,3 +1,4 @@
+@use "@styles/abstracts/functions" as fun;
 @use "@styles/abstracts/mixins" as mix;
 @use "@styles/abstracts/placeholders";
 
@@ -5,8 +6,9 @@
   @include mix.media("screen") {
     @include mix.dimensions("md") {
       display: grid;
-      grid-template-columns: max-content minmax(0, 1fr);
+      grid-template-columns: fun.convert-px(150) minmax(0, 1fr);
       align-items: first baseline;
+      margin-left: fun.convert-px(-150);
     }
   }
 }
@@ -16,6 +18,7 @@
 
   .item {
     margin-bottom: var(--spacing-md);
+    border-bottom: fun.convert-px(1) solid var(--color-border);
   }
 }
 
@@ -24,9 +27,13 @@
     @include mix.dimensions("md") {
       grid-column: 1;
       justify-self: end;
-      margin-right: var(--spacing-lg);
+      padding-right: var(--spacing-lg);
       position: sticky;
       top: var(--spacing-xs);
     }
+
+    @include mix.dimensions("lg") {
+      padding-right: var(--spacing-xl);
+    }
   }
 }
diff --git a/src/components/organisms/layout/posts-list.stories.tsx b/src/components/organisms/layout/posts-list.stories.tsx
index 783d333..f80e1ca 100644
--- a/src/components/organisms/layout/posts-list.stories.tsx
+++ b/src/components/organisms/layout/posts-list.stories.tsx
@@ -171,6 +171,13 @@ ByYears.args = {
   posts,
   byYear: true,
 };
+ByYears.decorators = [
+  (Story) => (
+    <div style={{ marginLeft: 150 }}>
+      <Story />
+    </div>
+  ),
+];
 
 /**
  * PostsList Stories - No results
diff --git a/src/components/organisms/layout/summary.module.scss b/src/components/organisms/layout/summary.module.scss
index 3919e15..1cdda98 100644
--- a/src/components/organisms/layout/summary.module.scss
+++ b/src/components/organisms/layout/summary.module.scss
@@ -2,6 +2,8 @@
 @use "@styles/abstracts/mixins" as mix;
 
 .wrapper {
+  padding: var(--spacing-2xs) 0 var(--spacing-lg);
+
   @include mix.media("screen") {
     @include mix.dimensions("xs") {
       padding: var(--spacing-sm) var(--spacing-sm) var(--spacing-md);
diff --git a/src/components/templates/layout/layout.module.scss b/src/components/templates/layout/layout.module.scss
index eb84c70..3533257 100644
--- a/src/components/templates/layout/layout.module.scss
+++ b/src/components/templates/layout/layout.module.scss
@@ -11,7 +11,6 @@
 }
 
 .header {
-  padding: var(--spacing-sm) 0 var(--spacing-md);
   border-bottom: fun.convert-px(3) solid var(--color-border-light);
 }
 
diff --git a/src/components/templates/page/page-layout.module.scss b/src/components/templates/page/page-layout.module.scss
new file mode 100644
index 0000000..d5a1a2b
--- /dev/null
+++ b/src/components/templates/page/page-layout.module.scss
@@ -0,0 +1,85 @@
+@use "@styles/abstracts/functions" as fun;
+@use "@styles/abstracts/mixins" as mix;
+@use "@styles/abstracts/placeholders";
+
+.article {
+  @extend %grid;
+
+  grid-auto-flow: column dense;
+  align-items: baseline;
+
+  &--no-comments {
+    padding-bottom: var(--spacing-lg);
+  }
+}
+
+.breadcrumb {
+  @extend %grid;
+
+  grid-column: 1 / -1;
+  padding: var(--spacing-md) 0;
+
+  > * {
+    grid-column: 2;
+  }
+
+  &__items {
+    font-size: var(--font-size-sm);
+  }
+}
+
+.header {
+  grid-column: 1 / -1;
+  margin-bottom: var(--spacing-md);
+}
+
+.body {
+  grid-column: 2;
+}
+
+.sidebar {
+  grid-column: 2;
+
+  &--first {
+    margin-bottom: var(--spacing-xs);
+
+    @include mix.media("screen") {
+      @include mix.dimensions("lg") {
+        grid-column: 1;
+        align-self: stretch;
+        margin: 0 var(--spacing-xs) var(--spacing-md);
+      }
+    }
+  }
+
+  &--last {
+    margin: var(--spacing-lg) 0 0;
+
+    @include mix.media("screen") {
+      @include mix.dimensions("md") {
+        grid-column: 3;
+        align-self: stretch;
+        margin: 0 var(--spacing-xs) var(--spacing-md);
+      }
+    }
+  }
+}
+
+.footer {
+  grid-column: 2;
+}
+
+.comments {
+  @extend %grid;
+
+  grid-column: 1 / -1;
+  margin: var(--spacing-lg) 0 0;
+  padding: 0 0 var(--spacing-lg);
+  background: var(--color-bg-secondary);
+  border-top: fun.convert-px(3) solid var(--color-border-light);
+
+  &__section {
+    grid-column: 2;
+    margin: var(--spacing-md) 0 0;
+  }
+}
diff --git a/src/components/templates/page/page-layout.stories.tsx b/src/components/templates/page/page-layout.stories.tsx
new file mode 100644
index 0000000..1f72cb0
--- /dev/null
+++ b/src/components/templates/page/page-layout.stories.tsx
@@ -0,0 +1,520 @@
+import ButtonLink from '@components/atoms/buttons/button-link';
+import Heading from '@components/atoms/headings/heading';
+import Link from '@components/atoms/links/link';
+import ProgressBar from '@components/atoms/loaders/progress-bar';
+import PostsList from '@components/organisms/layout/posts-list';
+import LinksListWidget from '@components/organisms/widgets/links-list-widget';
+import Sharing from '@components/organisms/widgets/sharing';
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { IntlProvider } from 'react-intl';
+import PageLayoutComponent from './page-layout';
+
+/**
+ * PageLayout - Storybook Meta
+ */
+export default {
+  title: 'Templates/Page',
+  component: PageLayoutComponent,
+  args: {
+    allowComments: false,
+  },
+  argTypes: {
+    allowComments: {
+      control: {
+        type: 'boolean',
+      },
+      description: 'Determine if the comment form is displayed.',
+      table: {
+        category: 'Options',
+        defaultValue: { summary: false },
+      },
+      type: {
+        name: 'boolean',
+        required: false,
+      },
+    },
+    breadcrumb: {
+      description: 'The breadcrumb items.',
+      type: {
+        name: 'object',
+        required: true,
+        value: {},
+      },
+    },
+    children: {
+      control: {
+        type: 'text',
+      },
+      description: 'The page content.',
+      type: {
+        name: 'string',
+        required: true,
+      },
+    },
+    comments: {
+      description: 'The page comments.',
+      table: {
+        category: 'Options',
+      },
+      type: {
+        name: 'object',
+        required: false,
+        value: {},
+      },
+    },
+    footerMeta: {
+      description: 'The metadata to display in the page footer.',
+      table: {
+        category: 'Options',
+      },
+      type: {
+        name: 'object',
+        required: false,
+        value: {},
+      },
+    },
+    headerMeta: {
+      description: 'The metadata to display in the page header.',
+      table: {
+        category: 'Options',
+      },
+      type: {
+        name: 'object',
+        required: false,
+        value: {},
+      },
+    },
+    id: {
+      control: {
+        type: 'text',
+      },
+      description: 'The page id.',
+      type: {
+        name: 'string',
+        required: true,
+      },
+    },
+    intro: {
+      control: {
+        type: 'text',
+      },
+      description: 'The page introduction.',
+      table: {
+        category: 'Options',
+      },
+      type: {
+        name: 'string',
+        required: false,
+      },
+    },
+    isHome: {
+      control: {
+        type: 'boolean',
+      },
+      description: 'Determine if the current page is the homepage.',
+      table: {
+        category: 'Options',
+        defaultValue: { summary: false },
+      },
+      type: {
+        name: 'boolean',
+        required: false,
+      },
+    },
+    title: {
+      control: {
+        type: 'text',
+      },
+      description: 'The page title.',
+      type: {
+        name: 'string',
+        required: true,
+      },
+    },
+    widgets: {
+      control: {
+        type: null,
+      },
+      description: 'An array of widgets to put inside the last sidebar.',
+      type: {
+        name: 'object',
+        required: false,
+        value: {},
+      },
+    },
+    withToC: {
+      control: {
+        type: 'boolean',
+      },
+      description: 'Determine if the Table of Contents should be in the page.',
+      table: {
+        category: 'Options',
+        defaultValue: { summary: false },
+      },
+      type: {
+        name: 'boolean',
+        required: false,
+      },
+    },
+  },
+  decorators: [
+    (Story) => (
+      <IntlProvider locale="en">
+        <div id="__next">
+          <Story />
+        </div>
+      </IntlProvider>
+    ),
+  ],
+  parameters: {
+    layout: 'fullscreen',
+  },
+} as ComponentMeta<typeof PageLayoutComponent>;
+
+const Template: ComponentStory<typeof PageLayoutComponent> = (args) => (
+  <PageLayoutComponent {...args} />
+);
+
+const pageTitle = 'Incidunt ad earum';
+const pageIntro =
+  'Recusandae mollitia enim quo omnis rerum enim corporis ratione quidem. Pariatur omnis quas est ut ut numquam totam. Sunt sapiente nostrum aut sunt provident perspiciatis magni illum. Quidem nihil velit quasi fugit minima sint.';
+const pageBreadcrumb = [
+  { id: 'home', url: '#', name: 'Home' },
+  { id: 'page', url: '#', name: pageTitle },
+];
+
+/**
+ * Page Layout Stories - Single Page
+ */
+export const SinglePage = Template.bind({});
+SinglePage.args = {
+  breadcrumb: pageBreadcrumb,
+  title: pageTitle,
+  intro: pageIntro,
+  children: (
+    <>
+      <Heading level={2}>Impedit commodi rerum</Heading>
+      <p>
+        Omnis vel earum cupiditate delectus reprehenderit perferendis distinctio
+        omnis. Laudantium rem tempore eligendi porro officia est dolorum
+        assumenda. Corrupti tempore quia ab. Quidem est inventore. Autem
+        nesciunt sed rerum praesentium.
+      </p>
+      <p>
+        Illo nostrum inventore tenetur quo repellendus autem nisi nostrum
+        dolore. Et velit assumenda. Veniam harum officia et. Blanditiis et et
+        qui cum. Rerum illum quo doloribus neque non velit. Unde iusto et eaque
+        a ut.
+      </p>
+      <Heading level={2}>Et omnis ducimus</Heading>
+      <p>
+        Dolor quidem quas perferendis in nam molestiae. Accusamus quidem
+        accusantium quaerat est praesentium accusamus ab dolorem. Beatae illum
+        totam et corrupti assumenda corporis aut illo animi.
+      </p>
+      <p>
+        Ad rem soluta. Est tenetur consequatur sequi voluptates autem. Molestiae
+        in neque dignissimos. Dolorum numquam quos quam voluptas atque facilis
+        et. Accusantium fuga architecto excepturi consequatur libero est.
+      </p>
+    </>
+  ),
+  widgets: [
+    <Sharing
+      key="sidebar2-widget1"
+      data={{ excerpt: pageIntro, title: pageTitle, url: '#' }}
+      media={[
+        'diaspora',
+        'email',
+        'facebook',
+        'journal-du-hacker',
+        'linkedin',
+        'twitter',
+      ]}
+      title="Share"
+      level={2}
+      expanded={true}
+    />,
+  ],
+  withToC: true,
+};
+
+const postBreadcrumb = [
+  { id: 'home', url: '#', name: 'Home' },
+  { id: 'blog', url: '#', name: 'Blog' },
+  { id: 'post', url: '#', name: pageTitle },
+];
+
+const comments = [
+  {
+    author: {
+      avatar: 'http://placeimg.com/640/480',
+      name: 'Author 1',
+    },
+    content:
+      'Voluptas ducimus inventore. Libero ut et doloribus. Earum nostrum ab. Aliquam rem dolores omnis voluptate. Sunt aut ut et.',
+    id: 1,
+    publication: '2021-04-03 18:04:11',
+    // @ts-ignore - Needed because of the placeholder image.
+    unoptimized: true,
+  },
+  {
+    child: [
+      {
+        author: {
+          avatar: 'http://placeimg.com/640/480',
+          name: 'Author 4',
+        },
+        content:
+          'Vel ullam in porro tempore. Maiores quos quia magnam beatae nemo libero velit numquam. Sapiente aliquid cumque. Velit neque in adipisci aut assumenda voluptates earum. Autem esse autem provident in tempore. Aut distinctio dolor qui repellat et et adipisci velit aspernatur.',
+        id: 4,
+        publication: '2021-04-03 23:04:24',
+        // @ts-ignore - Needed because of the placeholder image.
+        unoptimized: true,
+      },
+      {
+        author: {
+          avatar: 'http://placeimg.com/640/480',
+          name: 'Author 1',
+        },
+        content:
+          'Sed non omnis. Quam porro est. Quae tempore quae. Exercitationem eos non velit voluptatem velit voluptas iusto. Sit debitis qui ipsam quo asperiores numquam veniam praesentium ut.',
+        id: 5,
+        publication: '2021-04-04 08:05:14',
+        // @ts-ignore - Needed because of the placeholder image.
+        unoptimized: true,
+      },
+    ],
+    author: {
+      avatar: 'http://placeimg.com/640/480',
+      name: 'Author 2',
+      url: '#',
+    },
+    content:
+      'Sit sed error quasi voluptatem velit voluptas aut. Aut debitis eveniet. Praesentium dolores quia voluptate vero quis dicta quasi vel. Aut voluptas accusantium ut aut quidem consectetur itaque laboriosam occaecati.',
+    id: 2,
+    publication: '2021-04-03 23:30:20',
+    // @ts-ignore - Needed because of the placeholder image.
+    unoptimized: true,
+  },
+  {
+    author: {
+      avatar: 'http://placeimg.com/640/480',
+      name: 'Author 3',
+    },
+    content:
+      'Natus consequatur maiores aperiam dolore eius nesciunt ut qui et. Ab ea nobis est. Eaque dolor corrupti id aut. Impedit architecto autem qui neque rerum ab dicta dignissimos voluptates.',
+    id: 3,
+    publication: '2021-09-13 13:24:54',
+    // @ts-ignore - Needed because of the placeholder image.
+    unoptimized: true,
+  },
+];
+
+/**
+ * Page Layout Stories - Post
+ */
+export const Post = Template.bind({});
+Post.args = {
+  breadcrumb: postBreadcrumb,
+  title: pageTitle,
+  intro: pageIntro,
+  headerMeta: {
+    publication: { name: 'Published on:', value: 'March 14th 2020' },
+    categories: {
+      name: 'Categories:',
+      value: [
+        <Link key="cat1" href="#">
+          Cat 1
+        </Link>,
+        <Link key="cat2" href="#">
+          Cat 2
+        </Link>,
+      ],
+    },
+  },
+  footerMeta: {
+    tags: {
+      name: 'Read more about:',
+      value: <ButtonLink target="#">Topic 1</ButtonLink>,
+    },
+  },
+  children: (
+    <>
+      <Heading level={2}>Impedit commodi rerum</Heading>
+      <p>
+        Omnis vel earum cupiditate delectus reprehenderit perferendis distinctio
+        omnis. Laudantium rem tempore eligendi porro officia est dolorum
+        assumenda. Corrupti tempore quia ab. Quidem est inventore. Autem
+        nesciunt sed rerum praesentium.
+      </p>
+      <p>
+        Illo nostrum inventore tenetur quo repellendus autem nisi nostrum
+        dolore. Et velit assumenda. Veniam harum officia et. Blanditiis et et
+        qui cum. Rerum illum quo doloribus neque non velit. Unde iusto et eaque
+        a ut.
+      </p>
+      <Heading level={2}>Et omnis ducimus</Heading>
+      <p>
+        Dolor quidem quas perferendis in nam molestiae. Accusamus quidem
+        accusantium quaerat est praesentium accusamus ab dolorem. Beatae illum
+        totam et corrupti assumenda corporis aut illo animi.
+      </p>
+      <p>
+        Ad rem soluta. Est tenetur consequatur sequi voluptates autem. Molestiae
+        in neque dignissimos. Dolorum numquam quos quam voluptas atque facilis
+        et. Accusantium fuga architecto excepturi consequatur libero est.
+      </p>
+    </>
+  ),
+  widgets: [
+    <Sharing
+      key="sidebar2-widget1"
+      data={{ excerpt: pageIntro, title: pageTitle, url: '#' }}
+      media={[
+        'diaspora',
+        'email',
+        'facebook',
+        'journal-du-hacker',
+        'linkedin',
+        'twitter',
+      ]}
+      title="Share"
+      level={2}
+      expanded={true}
+    />,
+  ],
+  withToC: true,
+  comments,
+  allowComments: true,
+};
+
+const postsListBreadcrumb = [
+  { id: 'home', url: '#', name: 'Home' },
+  { id: 'blog', url: '#', name: 'Blog' },
+];
+
+const posts = [
+  {
+    excerpt:
+      'Esse et voluptas sapiente modi impedit unde et. Ducimus nulla ea impedit sit placeat nihil assumenda. Rem est fugiat amet quo hic. Corrupti fuga quod animi autem dolorem ullam corrupti vel aut.',
+    id: 'post-1',
+    meta: {
+      publication: {
+        name: 'Published on:',
+        value: '2022-02-26T00:42:02',
+      },
+      readingTime: { name: 'Reading time:', value: '5 minutes' },
+      categories: {
+        name: 'Categories:',
+        value: [
+          <a key="cat-1" href="#">
+            Cat 1
+          </a>,
+          <a key="cat-2" href="#">
+            Cat 2
+          </a>,
+        ],
+      },
+      comments: { name: 'Comments:', value: '1 comment' },
+    },
+    title: 'Ratione velit fuga',
+    url: '#',
+    cover: {
+      alt: 'cover',
+      height: 480,
+      src: 'http://placeimg.com/640/480',
+      width: 640,
+      // @ts-ignore - Needed because of the placeholder image.
+      unoptimized: true,
+    },
+  },
+  {
+    excerpt:
+      'Illum quae asperiores quod aut necessitatibus itaque excepturi voluptas. Incidunt exercitationem ullam saepe alias consequatur sed. Quam veniam quaerat voluptatum earum quia quisquam fugiat sed perspiciatis. Et velit saepe est recusandae facilis eos eum ipsum.',
+    id: 'post-2',
+    meta: {
+      publication: {
+        name: 'Published on:',
+        value: '2022-02-20T10:40:00',
+      },
+      readingTime: { name: 'Reading time:', value: '8 minutes' },
+      categories: {
+        name: 'Categories:',
+        value: [
+          <a key="cat-2" href="#">
+            Cat 2
+          </a>,
+        ],
+      },
+      comments: { name: 'Comments:', value: '0 comments' },
+    },
+    title: 'Debitis laudantium laudantium',
+    url: '#',
+  },
+  {
+    excerpt:
+      'Sunt aperiam ut rem impedit dolor id sit. Reprehenderit ipsum iusto fugiat. Quaerat laboriosam magnam facilis. Totam sint aliquam voluptatem in quis laborum sunt eum. Enim aut debitis officiis porro iure quia nihil voluptas ipsum. Praesentium quis necessitatibus cumque quia qui velit quos dolorem.',
+    id: 'post-3',
+    meta: {
+      publication: {
+        name: 'Published on:',
+        value: '2021-12-20T15:12:02',
+      },
+      readingTime: { name: 'Reading time:', value: '3 minutes' },
+      categories: {
+        name: 'Categories:',
+        value: [
+          <a key="cat-1" href="#">
+            Cat 1
+          </a>,
+        ],
+      },
+      comments: { name: 'Comments:', value: '3 comments' },
+    },
+    title: 'Quaerat ut corporis',
+    url: '#',
+    cover: {
+      alt: 'cover',
+      height: 480,
+      src: 'http://placeimg.com/640/480',
+      width: 640,
+      // @ts-ignore - Needed because of the placeholder image.
+      unoptimized: true,
+    },
+  },
+];
+
+const blogCategories = [
+  { name: 'Cat 1', url: '#' },
+  {
+    name: 'Cat 2',
+    url: '#',
+  },
+  { name: 'Cat 3', url: '#' },
+  { name: 'Cat 4', url: '#' },
+];
+
+/**
+ * Page Layout Stories - Posts list
+ */
+export const Blog = Template.bind({});
+Blog.args = {
+  breadcrumb: postsListBreadcrumb,
+  title: 'Blog',
+  headerMeta: { total: { name: 'Total:', value: `${posts.length} posts` } },
+  children: (
+    <>
+      <PostsList posts={posts} byYear={true} />
+      <ProgressBar min={1} max={1} current={1} info="1/1 page loaded." />
+    </>
+  ),
+  widgets: [
+    <LinksListWidget
+      key="sidebar-widget1"
+      items={blogCategories}
+      title="Categories"
+      level={2}
+    />,
+  ],
+};
diff --git a/src/components/templates/page/page-layout.test.tsx b/src/components/templates/page/page-layout.test.tsx
new file mode 100644
index 0000000..b8fff6a
--- /dev/null
+++ b/src/components/templates/page/page-layout.test.tsx
@@ -0,0 +1,90 @@
+import { render, screen } from '@test-utils';
+import PageLayout from './page-layout';
+
+const title = 'Incidunt ad earum';
+const breadcrumb = [
+  { id: 'home', url: '#', name: 'Home' },
+  { id: 'page', url: '#', name: title },
+];
+const children =
+  'Reprehenderit aut quis aperiam magnam quia id. Vero enim animi placeat quia. Laborum sit odio minima. Dolores et debitis eaque iste quidem. Omnis aliquam illum porro ea non. Quaerat totam iste quos ex facilis officia accusantium.';
+
+describe('PageLayout', () => {
+  it('renders the page title', () => {
+    render(
+      <PageLayout breadcrumb={breadcrumb} title={title}>
+        {children}
+      </PageLayout>
+    );
+    expect(
+      screen.getByRole('heading', { level: 1, name: title })
+    ).toBeInTheDocument();
+  });
+
+  it('renders the page content', () => {
+    render(
+      <PageLayout breadcrumb={breadcrumb} title={title}>
+        {children}
+      </PageLayout>
+    );
+    expect(screen.getByText(children)).toBeInTheDocument();
+  });
+
+  it('renders the breadcrumb', () => {
+    render(
+      <PageLayout breadcrumb={breadcrumb} title={title}>
+        {children}
+      </PageLayout>
+    );
+    expect(
+      screen.getByRole('navigation', { name: 'Breadcrumb' })
+    ).toBeInTheDocument();
+  });
+
+  it('renders the table of contents', () => {
+    render(
+      <PageLayout breadcrumb={breadcrumb} title={title} withToC={true}>
+        {children}
+      </PageLayout>
+    );
+    expect(
+      screen.getByRole('heading', { level: 2, name: /Table of Contents/i })
+    ).toBeInTheDocument();
+  });
+
+  it('renders the comment form', () => {
+    render(
+      <PageLayout breadcrumb={breadcrumb} title={title} allowComments={true}>
+        {children}
+      </PageLayout>
+    );
+    expect(
+      screen.getByRole('form', { name: /Leave a comment/i })
+    ).toBeInTheDocument();
+  });
+
+  it('renders the comments list', () => {
+    const comments = [
+      {
+        author: {
+          avatar: 'http://placeimg.com/640/480',
+          name: 'Author 1',
+        },
+        content:
+          'Voluptas ducimus inventore. Libero ut et doloribus. Earum nostrum ab. Aliquam rem dolores omnis voluptate. Sunt aut ut et.',
+        id: 1,
+        publication: '2021-04-03 18:04:11',
+        // @ts-ignore - Needed because of the placeholder image.
+        unoptimized: true,
+      },
+    ];
+    render(
+      <PageLayout breadcrumb={breadcrumb} title={title} comments={comments}>
+        {children}
+      </PageLayout>
+    );
+    expect(
+      screen.getByRole('heading', { level: 2, name: /Comments/i })
+    ).toBeInTheDocument();
+  });
+});
diff --git a/src/components/templates/page/page-layout.tsx b/src/components/templates/page/page-layout.tsx
new file mode 100644
index 0000000..24c4e50
--- /dev/null
+++ b/src/components/templates/page/page-layout.tsx
@@ -0,0 +1,166 @@
+import Heading from '@components/atoms/headings/heading';
+import Sidebar from '@components/atoms/layout/sidebar';
+import PageFooter, {
+  type PageFooterProps,
+} from '@components/molecules/layout/page-footer';
+import PageHeader, {
+  type PageHeaderProps,
+} from '@components/molecules/layout/page-header';
+import Breadcrumb, {
+  type BreadcrumbItem,
+} from '@components/molecules/nav/breadcrumb';
+import CommentForm from '@components/organisms/forms/comment-form';
+import CommentsList, {
+  type CommentsListProps,
+} from '@components/organisms/layout/comments-list';
+import TableOfContents from '@components/organisms/widgets/table-of-contents';
+import useIsMounted from '@utils/hooks/use-is-mounted';
+import { FC, ReactNode, useRef } from 'react';
+import { useIntl } from 'react-intl';
+import Layout, { LayoutProps } from '../layout/layout';
+import styles from './page-layout.module.scss';
+
+export type PageLayoutProps = {
+  /**
+   * True if the page accepts new comments. Default: false.
+   */
+  allowComments?: boolean;
+  /**
+   * The breadcrumb items.
+   */
+  breadcrumb: BreadcrumbItem[];
+  /**
+   * The main content of the page.
+   */
+  children: ReactNode;
+  /**
+   * The page comments
+   */
+  comments?: CommentsListProps['comments'];
+  /**
+   * The footer metadata.
+   */
+  footerMeta?: PageFooterProps['meta'];
+  /**
+   * The header metadata.
+   */
+  headerMeta?: PageHeaderProps['meta'];
+  /**
+   * The page introduction.
+   */
+  intro?: PageHeaderProps['intro'];
+  /**
+   * True if it is homepage. Default: false.
+   */
+  isHome?: LayoutProps['isHome'];
+  /**
+   * The page title.
+   */
+  title: PageHeaderProps['title'];
+  /**
+   * An array of widgets to put in the last sidebar.
+   */
+  widgets?: ReactNode[];
+  /**
+   * Show the table of contents. Default: false.
+   */
+  withToC?: boolean;
+};
+
+/**
+ * PageLayout component
+ *
+ * Render the pages layout.
+ */
+const PageLayout: FC<PageLayoutProps> = ({
+  children,
+  allowComments = false,
+  breadcrumb,
+  comments,
+  footerMeta,
+  headerMeta,
+  intro,
+  isHome = false,
+  widgets,
+  title,
+  withToC = false,
+}) => {
+  const intl = useIntl();
+  const commentsTitle = intl.formatMessage({
+    defaultMessage: 'Comments',
+    description: 'PageLayout: comments title',
+    id: '+dJU3e',
+  });
+  const commentFormTitle = intl.formatMessage({
+    defaultMessage: 'Leave a comment',
+    description: 'PageLayout: comment form title',
+    id: 'kzIYoQ',
+  });
+
+  const bodyRef = useRef<HTMLDivElement>(null);
+  const isMounted = useIsMounted(bodyRef);
+
+  const hasComments = Array.isArray(comments) && comments.length > 0;
+  const hasCommentsSection = hasComments || allowComments;
+  const articleModifier = hasCommentsSection
+    ? 'article--has-comments'
+    : 'article--no-comments';
+
+  const saveComment = () => {
+    return null;
+  };
+
+  return (
+    <Layout
+      isHome={isHome}
+      className={`${styles.article} ${styles[articleModifier]}`}
+    >
+      <Breadcrumb
+        items={breadcrumb}
+        className={styles.breadcrumb}
+        itemClassName={styles.breadcrumb__items}
+      />
+      <PageHeader
+        title={title}
+        intro={intro}
+        meta={headerMeta}
+        className={styles.header}
+      />
+      {withToC && (
+        <Sidebar className={`${styles.sidebar} ${styles['sidebar--first']}`}>
+          {isMounted && bodyRef.current && (
+            <TableOfContents wrapper={bodyRef.current} />
+          )}
+        </Sidebar>
+      )}
+      <div ref={bodyRef} className={styles.body}>
+        {children}
+      </div>
+      <PageFooter meta={footerMeta} className={styles.footer} />
+      <Sidebar className={`${styles.sidebar} ${styles['sidebar--last']}`}>
+        {widgets}
+      </Sidebar>
+      {hasCommentsSection && (
+        <div className={styles.comments}>
+          {hasComments && (
+            <section className={styles.comments__section}>
+              <Heading level={2}>{commentsTitle}</Heading>
+              <CommentsList
+                saveComment={saveComment}
+                comments={comments}
+                depth={2}
+              />
+            </section>
+          )}
+          {allowComments && (
+            <section className={styles.comments__section}>
+              <CommentForm saveComment={saveComment} title={commentFormTitle} />
+            </section>
+          )}
+        </div>
+      )}
+    </Layout>
+  );
+};
+
+export default PageLayout;
diff --git a/src/styles/abstracts/_placeholders.scss b/src/styles/abstracts/_placeholders.scss
index d1c0a7a..18b1c03 100644
--- a/src/styles/abstracts/_placeholders.scss
+++ b/src/styles/abstracts/_placeholders.scss
@@ -1,3 +1,4 @@
 @forward "./placeholders/animations";
 @forward "./placeholders/clearfix";
+@forward "./placeholders/layout";
 @forward "./placeholders/list";
diff --git a/src/styles/abstracts/placeholders/_layout.scss b/src/styles/abstracts/placeholders/_layout.scss
new file mode 100644
index 0000000..1a28acb
--- /dev/null
+++ b/src/styles/abstracts/placeholders/_layout.scss
@@ -0,0 +1,25 @@
+@use "@styles/abstracts/mixins" as mix;
+
+%grid {
+  display: grid;
+  align-items: center;
+  grid-template-columns:
+    minmax(0, 1fr) min(calc(100vw - calc(var(--spacing-md) * 2)), 80ch)
+    var(--column-3, minmax(0, 1fr));
+  column-gap: var(--grid-gap, var(--spacing-md));
+
+  @include mix.media("screen") {
+    @include mix.dimensions("md") {
+      grid-template-columns:
+        minmax(0, 1fr) clamp(60ch, 60vw, 80ch)
+        var(--column-3, minmax(0, 3fr));
+      column-gap: var(--grid-gap, var(--spacing-xl));
+    }
+
+    @include mix.dimensions("lg") {
+      grid-template-columns:
+        minmax(0, 1fr) clamp(47ch, 47vw, 80ch)
+        var(--column-3, minmax(0, 1fr));
+    }
+  }
+}
diff --git a/src/utils/helpers/format.ts b/src/utils/helpers/format.ts
index dd35868..47a7b57 100644
--- a/src/utils/helpers/format.ts
+++ b/src/utils/helpers/format.ts
@@ -14,6 +14,7 @@ import {
   TopicPreview,
   Thematic,
 } from '@ts/types/taxonomies';
+import { settings } from '@utils/config';
 
 /**
  * Format a post preview from RawArticlePreview to ArticlePreview type.
@@ -269,11 +270,14 @@ export const getFormattedPost = (rawPost: RawArticle): Article => {
 
 /**
  * Converts a date to a string by using the specified locale.
- * @param {string} date The date.
- * @param {string} locale A locale.
+ * @param {string} date - The date.
+ * @param {string} [locale] - A locale.
  * @returns {string} The formatted date to locale date string.
  */
-export const getFormattedDate = (date: string, locale: string) => {
+export const getFormattedDate = (
+  date: string,
+  locale: string = settings.locales.defaultLocale
+): string => {
   const dateOptions: Intl.DateTimeFormatOptions = {
     day: 'numeric',
     month: 'long',
@@ -283,6 +287,24 @@ export const getFormattedDate = (date: string, locale: string) => {
   return new Date(date).toLocaleDateString(locale, dateOptions);
 };
 
+/**
+ * Converts a date to a time string by using the specified locale.
+ * @param {string} date - The date.
+ * @param {string} [locale] - A locale.
+ * @returns {string} The formatted time to locale date string.
+ */
+export const getFormattedTime = (
+  date: string,
+  locale: string = settings.locales.defaultLocale
+): string => {
+  const time = new Date(date).toLocaleTimeString(locale, {
+    hour: 'numeric',
+    minute: 'numeric',
+  });
+
+  return locale === 'fr' ? time.replace(':', 'h') : time;
+};
+
 /**
  * Convert an array of slugs to an array of params with slug.
  * @param {Slug} array - An array of object with slug.
diff --git a/src/utils/hooks/use-is-mounted.tsx b/src/utils/hooks/use-is-mounted.tsx
new file mode 100644
index 0000000..ca79afb
--- /dev/null
+++ b/src/utils/hooks/use-is-mounted.tsx
@@ -0,0 +1,19 @@
+import { RefObject, useEffect, useState } from 'react';
+
+/**
+ * Check if an HTML element is mounted.
+ *
+ * @param {RefObject<HTMLElement>} ref - A React reference to an HTML element.
+ * @returns {boolean} True if the HTML element is mounted.
+ */
+const useIsMounted = (ref: RefObject<HTMLElement>) => {
+  const [isMounted, setIsMounted] = useState<boolean>(false);
+
+  useEffect(() => {
+    if (ref.current) setIsMounted(true);
+  }, [ref]);
+
+  return isMounted;
+};
+
+export default useIsMounted;
-- 
cgit v1.2.3


From 056ed0d5f050158cebad689099214b164539899a Mon Sep 17 00:00:00 2001
From: Armand Philippot <git@armandphilippot.com>
Date: Sun, 15 May 2022 19:08:58 +0200
Subject: chore: improve accessibility

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

(limited to 'src/components/atoms/layout/sidebar.tsx')

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

