Creating a Blog - 8. Post List/Detail View Page

Table of Contents

Blog Creation Series

TitleLink
1. Basic Settingshttps://witch.work/posts/blog-remake-1
2. HTML Design of the Main Pagehttps://witch.work/posts/blog-remake-2
3. Structure Design of the Post Detail Pagehttps://witch.work/posts/blog-remake-3
4. Allowing Images to Use Relative Pathshttps://witch.work/posts/blog-remake-4
5. Minor Page Layout Improvements and Deploymenthttps://witch.work/posts/blog-remake-5
6. Layout Design of Page Elementshttps://witch.work/posts/blog-remake-6
7. Main Page Component Designhttps://witch.work/posts/blog-remake-7
8. Post List/Content Page Component Designhttps://witch.work/posts/blog-remake-8
9. Automatically Generate Post Thumbnailshttps://witch.work/posts/blog-remake-9
10. Design Improvements for Fonts and Cardshttps://witch.work/posts/blog-remake-10
11. Adding View Counts to Postshttps://witch.work/posts/blog-remake-11
12. Page Theme and Post Search Functionalityhttps://witch.work/posts/blog-remake-12
13. Improving Theme Icons and Thumbnail Layouts, etc.https://witch.work/posts/blog-remake-13
14. Changing Post Classification to Tag-basedhttps://witch.work/posts/blog-remake-14
Optimizing Calculations on Main Pagehttps://witch.work/posts/blog-opt-1
Creating Pagination for Post Listhttps://witch.work/posts/blog-opt-2
Uploading Images to CDN and Creating Placeholdershttps://witch.work/posts/blog-opt-3
Implementing Infinite Scroll in the Search Pagehttps://witch.work/posts/blog-opt-4

I was creating the Card component for the main page, but since Card is also used in the post category page, I wanted to work on creating thumbnails for that.

However, as I began designing it, I realized there were many preliminary tasks that needed to be done, so I decided to first make some minor adjustments to other pages. This post will cover those minor adjustments to the other pages. The thumbnail creation will be addressed in the next post.

1. Modifying the Post List Page

In fact, the purpose of the Card component seen on the main page has already been created in previous posts. While there is still room for design improvement, all the necessary content is already included. However, since Card is also used to display a list of posts by category, we need to design it appropriately for that context.

However, we need to see how the Card component is utilized in the post list page, but the post list page has not been designed yet. Therefore, let’s make a few changes just to help us visualize the Card component better on the post list page.

I will address it more thoroughly later. (In fact, the post list page is not complex, so if we design the card well, I don't think there will be any major design concerns afterwards.)

We will first wrap the entire post list in a container with a width of 92%, just as we did on the main page, and remove the default styles of the list (ul). We will also add a bit of space between the post blocks. This alone should be enough to create a layout where we can check if the thumbnails fit well.

Open the pages/posts/[category]/index.tsx file responsible for the post list page and modify the PostListPage component as follows:

function PostListPage({
  category, postList,
}: InferGetStaticPropsType<typeof getStaticProps>) {
  return (
    <main className={styles.pagewrapper}>
    {/* container and list classes were added */}
      <div className={styles.container}>
        <h1>{category}</h1>
        <ul className={styles.list}>
          {postList.map((post: PostMetaData) => 
            <li key={post.url}>
              <Card {...post} />
            </li>
          )}
        </ul>
      </div>
    </main>
  );
}

Then, the CSS for the newly added classes will be written as follows. Since it will maintain the same layout regardless of the width, there is no need for media queries.

// Add the following to pages/posts/[category]/style.module.css
.container {
  width: 92%;
  margin: 0 auto;
}

.list {
  list-style: none;
  padding: 0;
  margin: 0;
  display: flex;
  flex-direction: column;
  gap: 1rem;
}

2. Applying Global CSS

2.1. Global Style

Before creating the post detail view page, a problem arose. I checked the deployed blog on mobile, and the font types and spacing appeared slightly different. Therefore, let's apply global reset CSS.

Edit src/styles/globals.css.

I mostly followed the global CSS from gatsby-starter-lavender.

// src/styles/globals.css
:root {
  --white: #fff;
  --black: #000;

  --gray0: #f8f9fa;
  --gray1: #f1f3f5;
  --gray2: #e9ecef;
  --gray3: #dee2e6;
  --gray4: #ced4da;
  --gray5: #adb5bd;
  --gray6: #868e96;
  --gray7: #495057;
  --gray8: #343a40;
  --gray9: #212529;

  --indigo0: #edf2ff;
  --indigo1: #dbe4ff;
  --indigo2: #bac8ff;
  --indigo3: #91a7ff;
  --indigo4: #748ffc;
  --indigo5: #5c7cfa;
  --indigo6: #4c6ef5;
  --indigo7: #4263eb;
  --indigo8: #3b5bdb;
  --indigo9: #364fc7;

  font-family: "Pretendard", apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
  text-rendering: optimizeLegibility;
}

* {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

html, body {
  min-height: 100vh;
}

a {
  color: inherit;
  text-decoration: none;
}

h1 {
  font-size: 1.75rem;
}

h2 {
  font-size: 1.5rem;
}

h3 {
  font-size: 1.25rem;
}

h4 {
  font-size: 1rem;
}

h5 {
  font-size: 0.875rem;
}

h6 {
  font-size: 0.75rem;
}

hr {
  margin: 0.25rem 0;
  border: 0;
  border-top: 0.125rem solid var(--gray5);
}

img {
  display: block;
  margin: 0 auto;
}

p {
  margin: 0.75rem 0;
  line-height: 1.625rem;
}

table {
  width: 100%;
  margin: 0.75rem 0;
  border-collapse: collapse;
  line-height: 1.75rem;
}

tr {
  border-bottom: 1px solid var(--gray5);
}

th, td {
  padding: 0.75rem 0;
}

blockquote {
  padding-left: 1rem;
  border-left: 0.25rem solid var(--indigo7);
}

article {
  overflow-wrap: break-word;
}

article :is(ul, ol) {
  margin-left: 2rem;
}

article :is(ul, ol) :is(ul, ol) {
  margin-left: 1.5rem;
}

article :is(ul, ol) li {
  margin: 0.375rem 0;
}

article :is(ul, ol) li p {
  margin: 0;
}

article pre[class^="language-"] {
  border-radius: 0.25rem;
}

pre[class*="language-"], code[class*="language-"] {
  white-space: 'pre-wrap';
}

After applying this, the layout of the main page may change slightly. Let's make adjustments to restore it back to its original state.

There aren’t many layout elements to consider internally, and since classes have been applied to almost everything, there is nothing to fix.

2.3. Introduction Component

Links in my introduction are included in the ul that is a descendant of the article tag, hence they are affected by the global CSS. Therefore, we will remove the default margin and reduce the line height of the p tag. Additionally, we will create a linkbox class to remove the margin of the li tag that wraps the links.

// src/components/profile/intro/styles.module.css
.description {
  margin: 10px 0;
  word-break: keep-all;
  line-height: 1.2;
}

.linklist {
  display: flex;
  flex-direction: row;
  list-style: none;
  padding-left: 0;
  margin: 0;
  margin-bottom: 0.5rem;
  gap: 0 15px;
}

.link {
  text-decoration: none;
  color: var(--indigo6);
}

.linkbox {
  margin: 0;
}

Add the linkbox class to the part that wraps the links inside the Intro component:

// src/components/profile/intro/index.tsx snippet
<ul className={styles.linklist}>
  {Object.entries(blogConfig.social).map(([key, value]) => (
    {/* Add the linkbox class here */}
    <li key={key} className={styles.linkbox}>
      <Link href={value} target='_blank' className={styles.link}>
        {key}
      </Link>
    </li>
  ))}
</ul>

2.4. Project Introduction Component

The first thing to address is the left margin of the ul tag and the vertical margin of the li elements. Let's remove these. First, we will apply the styles.container class to the article tag that wraps the entire projectList, then we will handle the internals of ul and li.

// Add to src/components/projectList/styles.module.css
.container ul {
  margin-left: 0;
}

.container li {
  margin: 0;
}

Currently, spacing between individual project components mixes margin and grid display gaps, so let's unify this to use gaps.

Set the gap for the list class of the projectList to 1rem.

// Add to src/components/projectList/styles.module.css
.list {
  list-style: none;
  padding: 0;
  display: grid;
  grid-template-columns: 1fr;
  grid-template-rows: 1fr;
  gap: 1rem;
}

Remove the margin from the project component's container. To align the title of the projectList and the project image on the same line, set the left padding of the container to 0. Also, remove the margin: 0 auto; setting for the project image to correct its alignment.

// src/components/projectList/project/styles.module.css
.container {
  display: flex;
  flex-direction: row;
  gap: 1rem;
  box-sizing: border-box;
  /* padding has been modified */
  padding: 15px 15px 15px 0;
  min-height: 150px;
}

.image {
  border-radius: 1rem;
  /* margin set to 0 */
  margin: 0;
}

@media (min-width: 768px) {
  .container {
    /* padding modified */
    padding: 10px 10px 10px 0;
  }

  .image {
    display: block;
  }
}

If the projectList is collapsed, there was a problem with row-gap, causing extra space to appear at the bottom. To prevent this, set the row-gap to zero when the list is closed.

However, simply doing this will leave the list--close class applied even when the screen width is large, leading to issues where row-gaps are removed in wide viewports. Therefore, let's apply an appropriate gap when the viewport is wide.

.list--close {
  grid-auto-rows: 0;
  overflow: hidden;
  // Disable row-gap when closed
  row-gap: 0;
}

@media (min-width: 768px) {
  .list {
    display: grid;
    grid-template-columns: repeat(2, 1fr);
    grid-auto-rows: 1fr;
    // Increase gaps when the screen width is wide
    row-gap: 1rem;
    column-gap: 2rem;
  }
}

Lastly, add a small bottom margin to the container of the projectList, which no longer has spacing from the component below.

// src/components/projectList/styles.module.css
.container {
  margin-bottom: 2rem;
}

2.5. Post Category Component

Let's make adjustments to the Category and Card components.

For the Category, simply remove the margin from the ul and provide a little bottom spacing in the container for separation.

// src/components/category/index.tsx
function Category(props: Props) {
  return (
    <section className={styles.container}>
      <h2 className={styles.title}>{props.title}</h2>
      
      <ul className={styles.list}>
        {props.items.map((item) => {
          return (
            <li key={item.url}>
              <Card
                {...propsProperty(item)}
              />
            </li>
          );
        })}
      </ul>
    </section>
  );
}

Thus the CSS would look like this.

// src/components/category/styles.module.css
.container {
  margin-bottom: 2rem;
}

.list {
  list-style: none;
  padding: 0;
  display: grid;
  gap: 1rem;
  margin: 0;
}

@media (min-width: 768px) {
  .list {
    grid-template-columns: repeat(3, 1fr);
  }
}

As for the Card, remove the left padding to align with the category title, and only add padding-left on hover to provide a slight effect. We only need to edit the link class.

// src/components/card/styles.module.css
.link {
  display: block;
  height: 100%;
  padding: 1rem;
  padding-left: 0;
  text-decoration: none;
  color: var(--black);
}

.link:hover {
  padding-left: 1rem;
  border-radius: 1rem;
  color: var(--indigo6);
  background-color: var(--gray1);
}

3. Post Detail Page

3.1. Container Layout

Let's edit styles.module.css located in the /pages/posts/[category]/[slug].tsx directory.

We will give a wrapper class to the position where the post content will go and style it. The class name will simply be content.

// src/pages/posts/[category]/[slug].tsx
function PostPage({
  post
}: InferGetStaticPropsType<typeof getStaticProps>) {
  return (
    <main className={styles.pagewrapper}>
      <article className={styles.container}>
        <h1>{post.title}</h1>
        <time>{post.date}</time>
        <ul>
          {post.tags.map((tag: string) => <li key={tag}>{tag}</li>)}
        </ul>
        {'code' in post.body ?
        {/* wrapper class for post */}
          <div className={styles.content}>
            <MDXComponent code={post.body.code} />
          </div>
          :
          <div 
            className={styles.content} 
            dangerouslySetInnerHTML={{ __html: post.body.html }} 
          />
        }
      </article>
    </main>
  );
}

For example, if we want to style the h1 tag within the post, we can apply styles to the selector .content h1. It seems to become clear why CSS in JS has become popular compared to using styled-components now.

Let's tidy up the folder structure a bit. First, let’s separate the [slug].tsx file into its own folder. Create a folder pages/posts/[category]/[slug] and within it create index.tsx and styles.module.css, then move the content from the original [slug].tsx into the newly created index.tsx.

Next, let's separate the CSS related to the post content into a different CSS module file. In the pages/posts/[category]/[slug], create content.module.css and create a .content class. The current folder [slug] will have index.tsx, styles.module.css, and content.module.css, with the contents being as follows. The getStaticPaths, getStaticProps are omitted since they have been explained in previous posts.

import {
  GetStaticPaths,
  GetStaticProps,
  InferGetStaticPropsType,
} from 'next';
import { useMDXComponent } from 'next-contentlayer/hooks';

import { getSortedPosts } from '@/utils/post';

import contentStyles from './content.module.css';
import styles from './styles.module.css';

interface MDXProps {
  code: string;
}

function MDXComponent(props: MDXProps) {
  const MDX = useMDXComponent(props.code);
  return <MDX />;
}

function PostPage({
  post
}: InferGetStaticPropsType<typeof getStaticProps>) {
  return (
    <main className={styles.page}>
      <article className={styles.container}>
        <h1>{post.title}</h1>
        <time>{post.date}</time>
        <ul>
          {post.tags.map((tag: string) => <li key={tag}>{tag}</li>)}
        </ul>
        {'code' in post.body ?
          <div className={contentStyles.content}>
            <MDXComponent code={post.body.code} />
          </div>
          :
          <div
            className={contentStyles.content} 
            dangerouslySetInnerHTML={{ __html: post.body.html }} 
          />
        }
      </article>
    </main>
  );
}

export default PostPage;

content.module.css

// src/pages/posts/[category]/[slug]/content.module.css
.content {
  margin: 0 auto;
  width: 100%;
  min-height: 100vh;
}

styles.module.css

// src/pages/posts/[category]/[slug]/styles.module.css
.page {
  margin: 0 auto;
  width: 100%;
  min-height: 100vh;
}

.container {
  width: 92%;
  /*max-width: calc(100% - 48px);*/
  margin: 0 auto;
}

@media (min-width: 768px) {
  .page {
    max-width: 50rem;
  }
}

3.2. Post Content Layout

Now let’s create child selectors in the content class by editing src/pages/posts/[category]/[slug]/content.module.css.

There is a great reference to follow, which is the previously used gatsby-starter-lavender. Let's adopt everything from here.

// src/pages/posts/[category]/[slug]/content.module.css
.content {
  margin: 0 auto;
  width: 100%;
  min-height: 100vh;
  word-break: keep-all;
}

.content h1 {
  margin: 2rem 0 1.25rem 0;
  padding-bottom: 0.25rem;
  border-bottom: 1px solid var(--gray5);
  font-weight: 600;
}

.content h1 a {
  border-bottom: none;
}

.content h2 {
  margin: 1.5rem 0 1rem 0;
  padding-bottom: 0.25rem;
  border-bottom: 1px solid var(--gray5);
}

.content h2 a {
  border-bottom: none;
}

.content a {
  border-bottom: 1px solid var(--indigo7);
  color: var(--indigo7);
}

.content pre code {
  white-space: pre-wrap;
  word-break: break-all;
  overflow-wrap: break-word;
}

/* Options for merging dots */
.content :is(pre, code) {
  font-variant-ligatures: none;
}

To make the code look nice, let’s apply the rehype plugin in contentlayer.config.js as follows.

// contentlayer.config.js
const rehypePrettyCodeOptions = {
  theme: 'github-light',
};

export default makeSource({
  contentDirPath: 'posts',
  documentTypes: [MDXPost, Post],
  markdown: {
    remarkPlugins: [remarkGfm, changeImageSrc],
    rehypePlugins: [[rehypePrettyCode, rehypePrettyCodeOptions]],
  },
  mdx: {
    remarkPlugins: [remarkGfm, changeImageSrc],
    rehypePlugins: [[rehypePrettyCode, rehypePrettyCodeOptions], highlight],
  },
});

Still, the code font keeps showing as monospace. Therefore, go to content.styles.css and provide a new font-family for .content :is(pre, code).

Then, to set the background for the code block and more, add the following styles in the content.module.css.

// src/pages/posts/[category]/[slug]/content.module.css

.content pre code {
  white-space: pre-wrap;
  word-break: break-all;
  overflow-wrap: break-word;
}

/* Options for merging dots */
.content :is(pre, code) {
  font-family: monospace, Pretendard, apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
  font-variant-ligatures: none;
  font-size: 1rem;
  overflow: auto;
}

.content pre {
  margin: 1rem 0;
  padding: 1rem;
  border-radius: 0.5rem;
  background-color: var(--gray1);
  line-height: 1.5;
}

.content :not(pre) > code {
  padding: 0.25rem;
  border-radius: 0.25rem;
  background-color: var(--indigo0);
  color: var(--indigo9);
}

.content img {
  display: block;
  margin: 0 auto;
  max-width: 92%;
}

.content blockquote {
  border-left: 2px solid var(--gray5);
  padding-left: 1rem;
  color: var(--gray7);
}

.content p {
  line-height: 1.625;
  margin-bottom: 1.25rem;
}

.content p code {
  white-space: pre-wrap;
}

.content hr {
  border: 0;
  border-top: 1px solid var(--gray5);
  margin: 0.5rem 0;
}

To avoid having margins on both sides growing too narrow when the screen width becomes too small, let's set a max-width to keep the overall width less than 48px. (Taking reference from Toss Blog)

// src/pages/posts/[category]/[slug]/styles.module.css
.container {
  width: 92%;
  // Adding this
  max-width: calc(100% - 48px);
  margin: 0 auto;
}

3.3. Title and Tags of the Post

Now we can see the post content properly. However, the title, posted date, and tags are still displayed in their default styles. Let's provide suitable styles for these elements.

Add classes to the respective elements in PostPage located at src/pages/posts/[category]/[slug]/index.tsx. Date formatting will be added as well.

function PostPage({
  post
}: InferGetStaticPropsType<typeof getStaticProps>) {
  const dateObj = new Date(post.date);
  // Add SEO information
  const SEOInfo: NextSeoProps = {
    title: post.title,
    description: post.description,
    canonical: `${SEOConfig.canonical}${post.url}`,
    openGraph: {
      title: post.title,
      description: post.description,
      images: [
        {
          url: '/witch.jpeg',
          alt: `${blogConfig.name} profile picture`,
        },
      ],
      url: `${SEOConfig.canonical}${post.url}`,
    }
  };

  return (
    <main className={styles.page}>
      <NextSeo {...SEOInfo} />
      <article className={styles.container}>
        <h1 className={styles.title}>{post.title}</h1>
        <time className={styles.time} dateTime={toISODate(dateObj)}>
          {formatDate(dateObj)}
        </time>
        <ul className={styles.tagList}>
          {post.tags.map((tag: string)=>
            <li key={tag} className={styles.tag}>{tag}</li>
          )}
        </ul>
        <TableOfContents nodes={post._raw.headingTree} />
        {'code' in post.body ?
          <div className={contentStyles.content}>
            <MDXComponent code={post.body.code} />
          </div>
          :
          <div
            className={contentStyles.content} 
            dangerouslySetInnerHTML={{ __html: post.body.html }} 
          />
        }
      </article>
    </main>
  );
}

And the styling for each element will be as follows. Add margin to the container class to create space between the header and the post section, while the page class can remain as is.

// src/pages/posts/[category]/[slug]/styles.module.css
.container {
  width: 92%;
  max-width: calc(100% - 48px);
  margin: 0 auto;
  margin-top: 2rem;
}

.title {
  font-size: 2rem;
  font-weight: 700;
  margin-bottom: 1rem;
}

.time {
  display: block;
  font-size: 1.25rem;
  font-weight: 400;
  margin-bottom: 0.5rem;
}

.tagList {
  list-style: none;
  margin: 0;
  display: flex;
  flex-wrap: wrap;
  flex-direction: row;
  gap: 10px;
}

.tag {
  background-color: var(--indigo1);
  color: var(--indigo8);
  padding: 5px;
  border-radius: 5px;
}

4. Back to Post List Page

After creating the global CSS, I hadn't touched the post list page, but there are a few spacing issues appearing that need to be fixed.

4.1. Change the Way Topics are Retrieved

Currently, when entering the post list page, the topic display doesn’t show dev, misc, but has changed to 개발, 기타, which has not been applied yet.

This can be fixed by modifying getStaticProps in src/pages/posts/[category]/index.tsx. We will change the way the category is obtained so that it reflects the actual topic name.

/*
Modify getStaticProps in
src/pages/posts/[category]/index.tsx
*/
export const getStaticProps: GetStaticProps = ({params}) => {
  const allDocumentsInCategory = getSortedPosts().filter((post)=>
    post._raw.flattenedPath.startsWith(params?.category as string
    ));
  // Changed how category is obtained.
  const category = blogCategoryList.find((c)=>
    c.url.split('/').pop() === params?.category)?.title;

  const postList = allDocumentsInCategory.map((post) => ({
    title: post.title,
    description: post.description,
    date: post.date,
    tags: post.tags,
    url: post.url,
  }));
  return { props: { category, postList } };
};

4.2. Style Adjustments

Now let’s modify src/pages/posts/[category]/styles.module.css. We'll create a new title class and add some spacing to the container.

.page {
  margin: 0 auto;
  width: 100%;
  min-height: 100vh;
}

.container {
  width: 92%;
  max-width: calc(100% - 48px);
  margin: 0 auto;
  margin-top: 2rem;
}

.title {
  font-size: 1.5rem;
  margin-bottom: 0.5rem;
}

.list {
  list-style: none;
  padding: 0;
  margin: 0;
  display: flex;
  flex-direction: column;
  gap: 1rem;
}

@media (min-width: 768px) {
  .page {
    max-width: 60rem;
  }

  .title {
    font-size: 1.75rem;
    margin-bottom: 1rem;
  }
}

There’s still more to do, but since I have many other tasks ahead, I will stop the modifications for the post list page here.

5. Creating a Table of Contents (TOC)

TOC stands for Table of Contents, which represents the outline of the article. Let's create one manually, where headings from h1 to h6 in the markdown file will be parsed to generate the TOC.

5.1. Assigning IDs to Headings

First, we need to extract all headings from the markdown file and assign IDs to them. This can be done using the previously installed unist-util-visit. Let’s create a custom remark plugin for this as well, referencing this article.

Create src/plugins/heading-tree.mjs and write the following content.

The basic code for visiting h1 to h6 in the AST looks like this. For now, it simply outputs the heading nodes.

// src/plugins/heading-tree.mjs
import { visit } from 'unist-util-visit';

function getHeadings(tree) {
  visit(tree, 'heading', (node) => {
    console.log(node);
  });
}

export default function headingTree() {
  return (tree, file) => {
    getHeadings(tree);
  };
}

We can see that the heading nodes are outputting correctly. Now let's add IDs to each heading. First, we will create an addID function that will iterate through the mdast and add IDs to each heading element.

// src/plugins/heading-tree.mjs
function addID(node, headings) {
  const id = node.children.map(c => c.value).join('');
  headings[id] = (headings[id] || 0) + 1;
  node.data = node.data || {
    hProperties: {
      title: id,
      // Adding id is necessary in case of having multiple headings with the same title
      id: `${id}${(headings[id] > 1 ? `-${headings[id]}` : '')}`
        .split(' ')
        .join('-')
    }
  };
}

We utilize a headings object to track duplicates to prevent issues stemming from the possibility of multiple headings with the same title. Though it's rare, it’s a good precaution.

Also, we set both title and id in node.data under hProperties, as this is how properties must be set in heading elements. It is necessary that this is named hProperties, as that’s the required term when defining properties in HTML AST (hast) elements. Refer to hast Github.

5.2. Creating Heading Hierarchy

Next, we need to establish a hierarchical structure for the headings. There are h1 to h6 headings, and typically when using TOC, these headings have hierarchical relations.

For instance, if the headings are structured like this, it's natural to consider encapsulation, inheritance, and polymorphism headings as belonging under the "Characteristics of Object-Oriented Programming" heading.

# 1. Characteristics of Object-Oriented Programming
## 1.1. Encapsulation
## 1.2. Inheritance
## 1.3. Polymorphism

To implement this, we will create a function makeHeadingTree.

// src/plugins/heading-tree.mjs
function makeHeadingTree(node, output, depthMap) {
  const newNode = {
    data: node.data,
    depth: node.depth,
    children: [],
  };
  /* h1 will have no parent, thus push it directly to the headingTree output */
  if (node.depth === 1) {
    output.push(newNode);
    depthMap[node.depth] = newNode;
  } else {
    /* Using DFS, the most recently visited node with depth one less than the current node becomes the parent */
    const parent = depthMap[node.depth - 1];
    if (parent) {
      parent.children.push(newNode);
      /* Update the most recently visited node at this depth */
      depthMap[node.depth] = newNode;
    }
  }
}

5.3. Passing Data

Now, let's rename the earlier getHeading function to handleHeading, and incorporate the ID assignment and heading tree construction features as it iterates through heading elements.

// src/plugins/heading-tree.mjs
function handleHeading(tree) {
  const headings = {};
  const output = [];
  const depthMap = {};
  visit(tree, 'heading', (node) => {
    addID(node, headings);
    makeHeadingTree(node, output, depthMap);
    //console.log(node);
  });
  return output;
}

The handleHeading function will return the hierarchically structured headings. Now, how do we pass this to the converted files by contentlayer?

After examining console logs, we found that the file in the tree, file arguments received by the plugin's function contains file.data.rawDocumentData, which is transferred to the _raw property of the JSON files in contentlayer/generated.

Therefore, we will append the heading tree we created to file.data.rawDocumentData in the final plugin function headingTree.

// src/plugins/heading-tree.mjs
export default function headingTree() {
  return (tree, file) => {
    file.data.rawDocumentData.headingTree = handleHeading(tree);
  };
}

Next, we will add the headingTree plugin to contentlayer.config.ts.

export default makeSource({
  contentDirPath: 'posts',
  documentTypes: [MDXPost, Post],
  markdown: {
    /* added headingTree plugin! */
    remarkPlugins: [remarkGfm, changeImageSrc, headingTree],
    rehypePlugins: [[rehypePrettyCode, rehypePrettyCodeOptions]],
  },
  mdx: {
    remarkPlugins: [remarkGfm, changeImageSrc, headingTree],
    rehypePlugins: [[rehypePrettyCode, rehypePrettyCodeOptions], highlight],
  },
});

With this done, execute npm run dev, then navigate to contentlayer/generated and view a markdown file to find that the _raw property now contains the headingTree correctly structured inside.

5.4. Creating the TOC

With the hierarchical structure of the heading tree established, we can utilize the post conversion data found in getStaticProps of the src/pages/[category]/[slug]/index.tsx. We can access the heading tree through post._raw.headingTree.

Now, let’s create the component that will render it. Create a src/components/toc folder containing index.tsx and styles.module.css.

First, we will create a type for each node in the heading tree, noting that this is a recursive structure.

interface ContentType {
  data: {
    hProperties: {
      id: string;
      title: string;
    }
  };
  depth: number;
  children: ContentType[];
}

Next, we assume there’s a function renderContent that recursively renders the heading tree, and we will write the TOC component structure.

function TableOfContents({ nodes }: { nodes: ContentType[] }) {
  if (!nodes.length) return null;
  return (
    <section>
      <span>Table of Contents</span>
      {renderContent(nodes)}
    </section>
  );
}

export default TableOfContents;

The renderContent function will render the heading tree recursively within a <ul> element.

function renderContent(nodes: ContentType[]) {
  return (
    <ul>
      {nodes.map((node: ContentType) => (
        <li key={node.data.hProperties.id}>
          <a href={`#${node.data.hProperties.id}`}>{node.data.hProperties.title}</a>
          {node.children.length > 0 && renderContent(node.children)}
        </li>
      ))}
    </ul>
  );
}

Finally, place the TOC component in an appropriate position within the PostPage of src/pages/[category]/[slug]/index.tsx as follows:

<TableOfContents nodes={post._raw.headingTree} />

toc-layout

5.5. Smooth Scrolling and Styling

Currently, clicking on a TOC element scrolls to the respective heading without any smooth transition. Let's implement a smooth scrolling effect along with some simple styling.

The smooth scrolling is easy to implement. Just modify the scroll-behavior property in global CSS.

// src/styles/globals.css
html, body {
  min-height: 100vh;
  scroll-behavior: smooth;
}

Next, we will add the following classes to the TOC component.

function renderContent(nodes: ContentType[]) {
  return (
    <ul className={`${styles.list} ${nodes[0].depth - 1 ? '' : styles.list__h1}`}>
      {nodes.map((node: ContentType) => (
        <li key={node.data.hProperties.id} className={styles.item}>
          <a
            className={styles.link}
            href={`#${node.data.hProperties.id}`}
          >
            {node.data.hProperties.title}
          </a>
          {node.children.length > 0 && renderContent(node.children)}
        </li>
      ))}
    </ul>
  );
}

function TableOfContents({ nodes }: { nodes: ContentType[] }) {
  if (!nodes.length) return null;
  return (
    <section>
      <span className={styles.title}>Table of Contents</span>
      {renderContent(nodes)}
    </section>
  );
}

The styles for each class will look like this.

.title {
  display: block;
  font-size: 1.25rem;
  font-weight: 700;
  margin: 0.5rem 0;
}

.list {
  list-style: none;
  margin-left: 1.5rem;
  font-size: 0.875rem;
}

.list__h1 {
  margin-left: 0;
}

.item {
  margin: 0;
}

.link {
  color: var(--gray7);
  line-height: 1.75;
  text-decoration: underline;
}

.link:hover {
  color: var(--indigo6);
}

5.6. Scroll Position Issue

Currently, when navigating through the TOC, the headings scroll to the very top of the page, which means they can get obscured by the fixed header at the top of the page.

To solve this, we can use the CSS property scroll-margin.

Add the following for heading elements in src/pages/posts/[category]/[slug]/content.module.css.

/* Add to src/pages/posts/[category]/[slug]/content.module.css */
.content :is(h1, h2, h3, h4, h5, h6) {
  scroll-margin-top: 50px;
}

5.7. Indicate Progress in the Article

The TOC is currently placed at the top of the article content, but it would be better if the TOC were always displayed to the right of the content as the screen width increases. Let’s implement this.

This will be done using the Intersection Observer API, which will be performed asynchronously and thus will not load the scroll events and be more efficient.

Of course, we need to edit the src/components/toc/index.tsx. First, let’s separate the link component used in the TOC into its own TOCLink component.

Create a folder called src/components/toc/tocLink and inside create index.tsx and styles.module.css.

In index.tsx, we simply replicate the basic structure used within the TOC links.

// src/components/toc/tocLink/index.tsx
function TOCLink({ node }: { node: ContentType }) {
  return (
    <a
      className={styles.link}
      href={`#${node.data.hProperties.id}`}
    >
      {node.data.hProperties.title}
    </a>
  );
}

Next, let’s create a useHighlight hook that returns which heading ID is currently active based on scrolling. Using useEffect, it creates an IntersectionObserver when the hook renders and monitors changes to the heading elements. The hook returns the ID of the activated heading and a function to set the activated heading ID.

// src/components/toc/tocLink/index.tsx
function useHighLight(): [string, Dispatch<SetStateAction<string>>] {
  const observer = useRef<IntersectionObserver>();
  const [activeID, setActiveID] = useState<string>('');

  useEffect(() => {
    // Callback executed when changes occur
    const handleObserver = (entries: IntersectionObserverEntry[]) => {
      entries.forEach((entry) => {
        if (entry.isIntersecting) {
          setActiveID(entry.target.id);
        }
      });
    };

    observer.current = new IntersectionObserver(handleObserver, {
      rootMargin: '0px 0px -40% 0px',
    });

    const elements = document.querySelectorAll('h1[id], h2[id], h3[id], h4[id], h5[id], h6[id]');
    elements.forEach((element) => observer.current?.observe(element));
    return () => observer.current?.disconnect();
  }, []);

  return [activeID, setActiveID];
}

In the TOCLink component, use this hook to get the active heading’s ID, then check whether it matches the link’s ID for applying the active style.

// src/components/toc/tocLink/index.tsx
function TOCLink({ node }: { node: ContentType }) {
  const id = node.data.hProperties.id;
  const [activeID, setActiveID] = useHighLight();
  return (
    <a
      className={`${styles.link} ${activeID === id ? styles.link__active : ''}`}
      href={`#${node.data.hProperties.id}`}
      onClick={() => setActiveID(id)}
    >
      {node.data.hProperties.title}
    </a>
  );
}

The styles for TOCLink will be:

// src/components/toc/tocLink/styles.module.css
.link {
  color: var(--gray7);
  line-height: 1.75;
  text-decoration: underline;
}

.link:hover {
  color: var(--indigo6);
}

.link__active {
  background-color: var(--indigo1);
  color: var(--indigo8);
  padding: 3px;
  border-radius: 5px;
}

.link__active:hover {
  background-color: var(--indigo2);
}

With this setup, the TOC becomes reactive to scrolling. However, the issue remains that it’s currently at the very top of the content, which means we can’t see the TOC moving along with the scroll.

Thus, when there is sufficient screen width, let’s ensure the TOC stays fixed to the right side of the content area.

This can be achieved by applying a fixed position to the container class enclosing the TOC, along with adequate margins.

// src/components/toc/styles.module.css
@media (min-width: 1280px) {
  .container {
    position: fixed;
    top: 50px;
    left: calc(50% + 25rem);
    margin-top: 2rem;
  }
}

The left margin is calculated using the calc function as the content area width is capped at 50rem at widths larger than 1280px. Therefore, this ensures the TOC will be accurately fixed relative to the content area.

6. Changing Favicon (+ SEO)

Do you recall how we filled the Head tag with various meta-information on the main page some time ago? It included the title and much more.

// src/pages/index.tsx
<Head>
  <title>{blogConfig.title}</title>
  <meta name='description' content={blogConfig.description} />
  <meta name='viewport' content='width=device-width, initial-scale=1' />
  <meta name='og:image' content={blogConfig.thumbnail} />
  <meta name='twitter:image' content={blogConfig.thumbnail} />
  <link rel='apple-touch-icon' sizes='180x180' href='/apple-touch-icon.png' />
  <link rel='icon' href='/witch-hat.svg' />
  <link rel='manifest' href='/site.webmanifest' />
  <link rel='canonical' href='https://witch.work/' />
</Head>

Now it's time to enhance the SEO and update the favicon with the previously found SVG witch hat.

6.1. Installing next-seo

Earlier we inserted metadata using the next Head element. However, using next-seo simplifies the process. Let’s install it.

npm install next-seo

Next, we will create a configuration object for SEO in blog-config.ts.

// /blog-config.ts
export const SEOConfig: NextSeoProps = {
  title: blogConfig.title,
  description: blogConfig.description,
  canonical: blogConfig.url,
  openGraph: {
    type: 'website',
    locale: 'ko_KR',
    title: blogConfig.title,
    description: blogConfig.description,
    url: blogConfig.url,
    siteName: blogConfig.title,
    images: [
      {
        url: '/witch.jpeg',
        alt: `${blogConfig.name} profile picture`,
      },
    ],
  },
};

This will be applied in src/pages/_app.tsx using the DefaultSeo component.

// src/pages/_app.tsx
export default function App({ Component, pageProps }: AppProps) {
  return (
    <>
      <DefaultSeo {...SEOConfig} />
      <Header navList={blogCategoryList} />
      <Component {...pageProps} />
      <Footer />
    </>
  );
}

Now let’s handle SEO for each post. Using the NextSeo component makes it easy. First, let's handle the individual post pages.

// src/pages/posts/[category]/[slug]/index.tsx
function PostPage({
  post
}: InferGetStaticPropsType<typeof getStaticProps>) {
  const dateObj = new Date(post.date);
  // Add SEO information
  const SEOInfo: NextSeoProps = {
    title: post.title,
    description: post.description,
    canonical: `${SEOConfig.canonical}${post.url}`,
    openGraph: {
      title: post.title,
      description: post.description,
      images: [
        {
          url: '/witch.jpeg',
          alt: `${blogConfig.name} profile picture`,
        },
      ],
      url: `${SEOConfig.canonical}${post.url}`,
    }
  };

  return (
    <main className={styles.page}>
      <NextSeo {...SEOInfo} />
      <article className={styles.container}>
        <h1 className={styles.title}>{post.title}</h1>
        <time className={styles.time} dateTime={toISODate(dateObj)}>
          {formatDate(dateObj)}
        </time>
        <ul className={styles.tagList}>
          {post.tags.map((tag: string) =>
            <li key={tag} className={styles.tag}>{tag}</li>
          )}
        </ul>
        <TableOfContents nodes={post._raw.headingTree} />
        {'code' in post.body ?
          <div className={contentStyles.content}>
            <MDXComponent code={post.body.code} />
          </div>
          :
          <div
            className={contentStyles.content}
            dangerouslySetInnerHTML={{ __html: post.body.html }}
          />
        }
      </article>
    </main>
  );
}

Similarly, for the PostListPage, we will add relevant SEO.

// src/pages/posts/[category]/index.tsx
const SEOInfo: NextSeoProps = {
  title: `${category} Topic Posts`,
  description: `${category} Topic Post Collection Page`,
  openGraph: {
    title: `${category} Topic Posts`,
    description: `${category} Topic Post Collection Page`,
    images: [
      {
        url: '/witch.jpeg',
        alt: `${blogConfig.name} profile picture`,
      },
    ],
  }
};
// Add this component inside the PostListPage
<NextSeo {...SEOInfo} />

6.2. Installing next-sitemap

Let’s also set up the site map automatically by using next-sitemap. First, install the package.

npm i next-sitemap

Then, create a config file. Create next-sitemap.config.js in the root directory and write the following:

/** @type {import('next-sitemap').IConfig} */

module.exports = {
  // My blog apex URL
  siteUrl: process.env.SITE_URL || 'https://witch.work',
  generateRobotsTxt: true, // (optional)
  // ...other options
};

Now, we will modify the postbuild command to generate the sitemap after the build is fully finished.

// package.json
"scripts": {
  "copyimages": "node ./src/bin/pre-build.mjs",
  "prebuild": "npm run copyimages",
  // Add this
  "postbuild": "next-sitemap",
  "predev": "npm run copyimages",
  "dev": "next dev",
  "build": "contentlayer build && next build",
  "start": "next start",
  "lint": "next lint"
},

Now, running npm run build will generate the sitemap.xml in the public folder. It will also automatically create a robots.txt.

6.3. Changing the Favicon

To change the favicon, we can amend the default SEO config. In src/blog-config.ts, add additionalLinkTags.

// blog-config.ts
export const SEOConfig: NextSeoProps = {
  title: blogConfig.title,
  description: blogConfig.description,
  canonical: blogConfig.url,
  openGraph: {
    type: 'website',
    locale: 'ko_KR',
    title: blogConfig.title,
    description: blogConfig.description,
    url: blogConfig.url,
    siteName: blogConfig.title,
    images: [
      {
        url: blogConfig.picture,
        alt: `${blogConfig.name} profile picture`,
      },
    ],
  },
  additionalLinkTags: [
    {
      rel: 'icon',
      href: '/witch-hat.svg',
    },
    {
      rel: 'mask-icon',
      href: '/witch-hat.svg',
      color: '#000000'
    },
    {
      rel: 'apple-touch-icon',
      href: '/witch-hat.png',
    }
  ]
};

While at it, let’s remove the Head tag from main page. But is there anything that should be left? After checking against what’s being handled by next-seo, we can leave the following content.

// Remaining head tag in src/pages/index.tsx
<Head>
  <meta name='viewport' content='width=device-width, initial-scale=1' />
  <link rel='manifest' href='/site.webmanifest' />
</Head>

As both the viewport setting and the manifest should apply to all pages, let’s move them from pages/index.tsx to the universal _app.tsx as below.

// src/pages/_app.tsx
export default function App({ Component, pageProps }: AppProps) {
  return (
    <>
      <Head>
        <meta name='viewport' content='width=device-width, initial-scale=1' />
        <link rel='manifest' href='/site.webmanifest' />
      </Head>
      <DefaultSeo {...SEOConfig} />
      <Header navList={blogCategoryList} />
      <Component {...pageProps} />
      <Footer />
    </>
  );
}

6.4. Issue with KakaoTalk Previews

However, one problem has arisen. Sending links to specific post pages provides proper previews, while sending links to the main page or post list page does not.

kakao-problem

Since KakaoTalk uses the og:image for parsing, I checked and confirmed those elements are present within the head. It seems like it fails to retrieve that image.

Is the image actually available? The image used as a preview for all pages is /witch.jpeg. Checking at this link, I can see that the image is indeed present with the deployment.

Thus, let’s convert the URLs for the images pulled in NextSeo and DefaultSeo. First, modify the blog-config.ts SEOConfig to update its image URL.

// blog-config.ts
export const SEOConfig: NextSeoProps = {
  /* omitted */
  openGraph: {
    type: 'website',
    locale: 'ko_KR',
    title: blogConfig.title,
    description: blogConfig.description,
    url: blogConfig.url,
    siteName: blogConfig.title,
    images: [
      {
        // Now using the blog URL concatenated with the image path
        url: `${blogConfig.url}${blogConfig.thumbnail}`,
        alt: `${blogConfig.name} profile picture`,
      },
    ],
  },
  /* omitted */
}

Now, update the og:image URL on main and post list pages as well.

The NextSeo property for the post list page should also have the og:url.

Going ahead, we will edit getStaticProps to retrieve the post list URLs.

export const getStaticProps: GetStaticProps = ({ params }) => {
  const allDocumentsInCategory = getSortedPosts().filter((post) =>
    post._raw.flattenedPath.startsWith(params?.category as string)
  );

  const { title: category, url: categoryURL } = blogCategoryList.find((c) =>
    c.url.split('/').pop() === params?.category) as { title: string, url: string };

  const postList = allDocumentsInCategory.map((post) => ({
    title: post.title,
    description: post.description,
    date: post.date,
    tags: post.tags,
    url: post.url,
  }));
  return { props: { category, categoryURL, postList } };
};

At the same time, we can add the canonical URL.

/* Inside src/pages/posts/[category]/index.tsx
Adjust props passed to the NextSeo component */
const SEOInfo: NextSeoProps = {
  title: `${category} Topic Posts`,
  description: `${category} Topic Post Collection Page`,
  canonical: `${blogConfig.url}${categoryURL}`,
  openGraph: {
    title: `${category} Topic Posts`,
    description: `${category} Topic Post Collection Page`,
    images: [
      {
        url: `${blogConfig.url}${blogConfig.thumbnail}`,
        alt: `${blogConfig.name} profile picture`,
      },
    ],
    url: `${blogConfig.url}${categoryURL}`,
  },
};

Now we can verify that the KakaoTalk previews show properly.

kakao-solved

References

https://gamguma.dev/post/2022/01/nextjs-blog-development-review

Creating TOC https://claritydev.net/blog/nextjs-blog-remark-interactive-table-of-contents

https://thisyujeong.dev/blog/toc-generator

Refer to heading structure in mdast https://github.com/syntax-tree/mdast-util-to-hast#hproperties

Using is to select multiple descendant tags https://stackoverflow.com/questions/11054305/css-select-multiple-descendants-of-another-element

Code formatting https://yiyb-blog.vercel.app/posts/nextjs-contentlayer-blog

https://maintainhoon.vercel.app/blog/post/blog_development_period

Adding IDs https://github.com/syntax-tree/mdast-util-to-hast

If you'd like to write plugins in TS... https://rokt33r.github.io/posts/contribute-definitely-typed

Using scroll-margin https://developer.mozilla.org/en-US/docs/Web/CSS/scroll-margin

Refer to sites on SEO and metadata

next-sitemap npm page https://www.npmjs.com/package/next-sitemap

Intersection observer https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API