Loading...
nextjs tailwindcss toc
Author Cloudapp
E.G.

Next.js 14 - Easy TOC creation and Code block highlight

April 29, 2024
Table of Contents

Structuring your content is great for both your users and search engines, but sometimes it leads to a lot of work for the authors/editors. Therefore, I want to show you a simple and automatic way to create a table of contents and highlight code blocks. This step-by-step guide will add three new components. We will use Contentful as the CMS, but you can use any CMS that provides JSON data.

The complete code is available in this GitHub repo.

Here's a spoiler for the finished website -> https://nextjs14-toc-codeblock-highlight-copy.vercel.app/testblogpost3

The link leads directly to the blog post "testblogpost3", which shows the changes made.

I will showcase how quickly and easily editors can automate certain parts of their work.

Here are all files that were created and/or modified.

Modified Files
Modified Files

Let’s start with the TOC Component ArticleToc.tsx. As you can see, the magic comes from TailwindCss and nothing else.

"use client";

import { useTranslation } from "@/app/i18n/client";
import type { LocaleTypes } from "@/app/i18n/settings";
import { useParams } from "next/navigation";
import Link from "next/link";

export const Toc = ({ headings }) => {
  const locale = useParams()?.locale as LocaleTypes;
  const { t } = useTranslation(locale, "common");
  
  if (headings.length === 0) {
    return null;
  }

  return (
    <div>   
      <details className="mb-10 rounded-xl bg-emerald-700/10 dark:bg-white/3">
        <summary className="flex dark:text-[#FAFAFA] text-gray700 items-center px-6 py-3 text-sm tracking-wide uppercase list-none select-none opacity-60">
          {t("article.toc")}
        </summary>
        <div className="p-6 pt-0">
          <ul className="space-y-2 list-disc list-inside">
            {headings.map((heading: string, index: number) => (
              <li key={index}>
                <Link
                  className="text-base no-underline text-emerald-600 dark:text-emerald-400"
                  href={`#${heading}`}
                >
                  {heading}
                </Link>
              </li>
            ))}
          </ul>
        </div>
      </details>
    </div>
  );
};

The next component is ArticleTocItem.tsx, which is quite simple and used for setting the Anchor-id. We need it to jump directly to the H2 if a user clicks on the corresponding entry in the TOC.

interface ArticleTocItemProps {
  dynamicId: string;
  heading: string;
}

export const ArticleTocItem: React.FC<ArticleTocItemProps> = ({
  dynamicId,
  heading,
}) => {
  return (
    <h2 id={dynamicId} className="text-2xl font-semibold dark:text-white">
      {heading}
    </h2>
  );
};

Both TOC components were then imported into CtfRichText.component.tsx, which is the main component used for visualizing the content on the front end.

import {
  documentToReactComponents,
  Options,
} from "@contentful/rich-text-react-renderer";
import { BLOCKS, MARKS, Document, INLINES } from "@contentful/rich-text-types";
import { ArticleImage } from "@/components/contentful/ArticleImage.component";
import { ComponentRichImage } from "@/lib/__generated/sdk";
import { CopyButton } from "@/components/contentful/ArticleCodeCopy";
import { Toc } from "@/components/contentful/ArticleToc";
import { ArticleTocItem } from "@/components/contentful/ArticleTocItem";
import { CtfPicture } from "@/components/contentful/CtfPicture.component";
import { twMerge } from "tailwind-merge";
import Link from "next/link";

export type EmbeddedEntryType = ComponentRichImage | null;

export interface ContentfulRichTextInterface {
  json: Document;
  links?:
    | {
        entries: {
          block: Array<EmbeddedEntryType>;
        };
      }
    | any;
  source: string;
}

function getFileName(text: string) {
  return text.split("#");
}

export const EmbeddedEntry = (entry: EmbeddedEntryType) => {
  switch (entry?.__typename) {
    case "ComponentRichImage":
      return <ArticleImage image={entry} />;
    default:
      return null;
  }
};

export const contentfulBaseRichTextOptions = ({
  links,
}: ContentfulRichTextInterface): Options => ({
  renderMark: {
    [MARKS.BOLD]: (text) => {
      return <b key={`${text}-key`}>{text}</b>;
    },
    [MARKS.CODE]: (text: any) => {
      let markedfilename = undefined;
      let showCodeText = text.toString() || "";
      const filename = getFileName(text.toString())[1];
      if (filename) {
        markedfilename = "#" + filename + "#";
        showCodeText = text.toString().replace(markedfilename, "");
        showCodeText = showCodeText.replace("#", "");
      }
      return (
        <pre>
          <div className="mb-3">
            {" "}
            <CopyButton text={text} />
          </div>
          <code key={`${text}-key`}>
            {markedfilename && (
              <span className="inline-block px-1 py-1 text-base text-white bg-[#3c4f6a] rounded-lg">
                {markedfilename}
              </span>
            )}
            {showCodeText}
            {/* {text.toString().replace(markedfilename, "")} */}
          </code>
        </pre>
      );
    },
    [MARKS.ITALIC]: (text) => {
      return <i key={`${text}-key`}>{text}</i>;
    },
  },
  renderNode: {
    [INLINES.HYPERLINK]: (node, children) => {
      if (node.data.uri.includes("https://")) {
        return (
          <a
            className="text-blue-500 underline hover:text-blue-700"
            target="_blank"
            rel="noopener noreferrer"
            href={node.data.uri}
          >
            {children}
          </a>
        );
      }

      return <Link href={node.data.uri}>{children}</Link>;
    },
    [BLOCKS.HEADING_2]: (node, children: any) => {
      return <ArticleTocItem dynamicId={children[0]} heading={children[0]} />;
    },

    [BLOCKS.EMBEDDED_ENTRY]: (node) => {
      const entry = links?.entries?.block?.find(
        (item: EmbeddedEntryType) => item?.sys?.id === node.data.target.sys.id
      );

      if (!entry) return null;

      return <EmbeddedEntry {...entry} />;
    },
    [BLOCKS.EMBEDDED_ASSET]: (node) => {
      const asset = links?.assets?.block?.find(
        (item: any) => item?.sys?.id === node.data.target.sys.id
      );
      if (!asset) return null;
      return (
        <>
          <figure>
            <div className="flex justify-center">
              <CtfPicture
                nextImageProps={{
                  className: twMerge(
                    "mt-0 mb-0 ",
                    "rounded-2xl border border-gray-300 shadow-lg"
                  ),
                }}
                {...asset}
              />
              ;
            </div>
          </figure>
        </>
      );
    },
  },
});

export const CtfRichText = ({
  json,
  links,
  source,
}: ContentfulRichTextInterface) => {
  const baseOptions = contentfulBaseRichTextOptions({ links, json, source });
  if (!json) return null; // IF there is no content, return null
  const jsoncontent: any = json.content;
  const headings: string[] = [];

  jsoncontent.map((item: any) => {
    if (item.nodeType === "heading-2") {
      const headingvalue: any = item.content[0].value;
      headings.push(headingvalue);
    }
  });
  return (
    <article className="prose prose-lg max-w-none">
      {source === "article" && <Toc headings={headings} />}
      {documentToReactComponents(json, baseOptions)}
    </article>
  );
};

In the code below, we set the Anchor-id with the ArticleTocItem component.

[BLOCKS.HEADING_2]: (node, children: any) => {
      return <ArticleTocItem dynamicId={children[0]} heading={children[0]} />;
}

At the end of the CtfRichText.component.tsx we loop through the json.content to create an array, which we use to show our TOC at the beginning of the blog post.

jsoncontent.map((item: any) => {
    if (item.nodeType === "heading-2") {
      const headingvalue: any = item.content[0].value;
      headings.push(headingvalue);
    }
  });
{source === "article" && <Toc headings={headings} />}

Code Block highlight and Copy function

As you may have already seen, we also imported the CopyButton component into CtfRichText.component.tsx.

import { CopyButton } from "@/components/contentful/ArticleCodeCopy";

In the MARKS.CODE section, we use the component to highlight the code and to show the Copy button.

 [MARKS.CODE]: (text: any) => {
      let markedfilename = undefined;
      let showCodeText = text.toString() || "";
      const filename = getFileName(text.toString())[1];
      if (filename) {
        markedfilename = "#" + filename + "#";
        showCodeText = text.toString().replace(markedfilename, "");
        showCodeText = showCodeText.replace("#", "");
      }
      return (
        <pre>
          <div className="mb-3">
            {" "}
            <CopyButton text={text} />
          </div>
          <code key={`${text}-key`}>
            {markedfilename && (
              <span className="inline-block px-1 py-1 text-base text-white bg-[#3c4f6a] rounded-lg">
                {markedfilename}
              </span>
            )}
            {showCodeText}
            {/* {text.toString().replace(markedfilename, "")} */}
          </code>
        </pre>
      );
    },

Component ArticleCodeCopy.tsx

"use client";

import { useState } from "react";
import type { LocaleTypes } from "@/app/i18n/settings";
import { useTranslation } from "@/app/i18n/client";
import { useParams } from "next/navigation";

interface CopyButtonProps {
  text: string;
}

export const CopyButton = ({ text }: CopyButtonProps) => {
  const [isCopied, setIsCopied] = useState(false);
  const locale = useParams()?.locale as LocaleTypes;
  const { t } = useTranslation(locale, "common");

  const copy = async () => {
    await navigator.clipboard.writeText(text);
    setIsCopied(true);

    setTimeout(() => {
      setIsCopied(false);
    }, 10000);
  };

  return (
    <div className="float-right px-2 py-1 text-base bg-gray-600 rounded">
      <button disabled={isCopied} onClick={copy}>
        {isCopied ? t("copybutton.afterclick")! : t("copybutton.beforeclick")}
      </button>
    </div>
  );
};

In Contentful (RichTextField), we must format the content correctly so the trick works.

H2-Titles will be used for the TOC.

TOC open
TOC open

Code formatted text parts will be highlighted, and the copy button will appear.

Copy Button Screenshot
Copy Button Screenshot

As a last step, we add new translations to the common.json file for every language, and we are done.

It is straightforward and can be built with Tailwind in minutes.

Cloudapp-dev, and before you leave us

Thank you for reading until the end. Before you go:

  • Please consider clapping and following the writer! 👏 on our Medium Account

Related articles