Adding a knowledge base or documentation hub to your Shipixen template

Updated on

In this guide, we will show you how to add a knowledge base or documentation hub to your Shipixen template.

That will work & look similar to the documentation hub you are currently reading.

We'll go through the following steps:

  • Add CSS
  • Add required components for Table of Contents and Scroll Restoration
  • Add blog post Hub layout & register it
  • Add example articles

Add required CSS

The blog post hub layout needs a few CSS classes to look properly. Add the following CSS to your globals.css file:

css/globals.css
/* ... */

  .nav-link {
    @apply opacity-100 text-slate-900 dark:text-slate-200;
  }

+  .toc-link {
+    @apply text-sm;
+  }
+
+  .toc-active-link {
+    @apply text-sm text-primary-500 dark:text-primary-400;
+  }
+
+  .toc-list,
+  .toc-list ul,
+  ul.toc-list {
+    @apply flex flex-col gap-2;
+  }
+
+  .toc-separator {
+    @apply border-b border-gray-300 dark:border-neutral-600;
+  }
+
+  .toc-title {
+    @apply mt-6 text-sm font-semibold text-black dark:text-white;
+  }

/* ... */

Add required components

The documentation hub can automatically render a table of contents for your articles, as well as maintain scroll position.

To do that, we need to create the following files:

components/blog/useTocHeadObserver.ts
import { useEffect, useState, useRef } from 'react';

export function useTocHeadObserver() {
  const observer = useRef<null | IntersectionObserver>();
  const [activeId, setActiveId] = useState('');

  useEffect(() => {
    const handleObserver = (entries) => {
      for (let i = 0; i < entries.length; i++) {
        const entry = entries[i];
        if (entry?.isIntersecting) {
          setActiveId('#' + entry.target.id);
        }
      }
    };

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

    const elements = document.querySelectorAll('h1 ,h2 ,h3 ,h4 ,h5 ,h6');
    elements.forEach((elem) => {
      if (observer.current) {
        observer.current.observe(elem);
      }
    });

    return () => observer.current?.disconnect();
  }, []);

  return { activeId };
}
components/blog/Toc.tsx
'use client';

import { useTocHeadObserver } from '@/components/blog/useTocHeadObserver';
import { cn } from '@/lib/utils';
import { Toc } from '@shipixen/pliny/mdx-plugins/remark-toc-headings';

export interface TOCProps {
  toc: Toc;
  indentDepth?: number;
  fromHeading?: number;
  toHeading?: number;
  asDisclosure?: boolean;
  exclude?: string | string[];
  className?: string;
}

/**
 * Generates an table of contents
 * Exclude titles matching this string (new RegExp('^(' + string + ')$', 'i')).
 * If an array is passed the array gets joined with a pipe (new RegExp('^(' + array.join('|') + ')$', 'i')).
 *
 * @param {TOCProps} {
 *   toc,
 *   indentDepth = 3,
 *   fromHeading = 1,
 *   toHeading = 6,
 *   asDisclosure = false,
 *   exclude = '',
 * }
 *
 */
const TOC = ({
  toc,
  indentDepth = 3,
  fromHeading = 1,
  toHeading = 6,
  asDisclosure = false,
  exclude = '',
  className,
}: TOCProps) => {
  const re = Array.isArray(exclude)
    ? new RegExp('^(' + exclude.join('|') + ')$', 'i')
    : new RegExp('^(' + exclude + ')$', 'i');

  const filteredToc = toc.filter(
    (heading) =>
      heading.depth >= fromHeading &&
      heading.depth <= toHeading &&
      !re.test(heading.value),
  );

  const { activeId } = useTocHeadObserver();

  const tocList = (
    <ul className={className}>
      {filteredToc.map((heading) => {
        return (
          <li
            key={heading.value}
            className={cn(
              `${heading.depth >= indentDepth ? 'ml-6' : ''}`,
              activeId === heading.url
                ? 'toc-active-link'
                : 'toc-link fancy-link fancy-link--no-underline',
            )}
          >
            <a href={heading.url}>{heading.value}</a>
          </li>
        );
      })}
    </ul>
  );

  return (
    <>
      {asDisclosure ? (
        <details open className={className}>
          <summary className="ml-6 pt-2 pb-2 text-xl font-bold">
            Table of Contents
          </summary>
          <div className="ml-6">{tocList}</div>
        </details>
      ) : (
        tocList
      )}
    </>
  );
};

export { TOC };
lib/ScrollRestoration.ts
'use client';
import { useRestoreScrollPosition } from '@/lib/useRestoreScroll';

export const ScrollRestoration = ({ elementId }: { elementId: string }) => {
  useRestoreScrollPosition({ elementId });
  return null;
};
lib/useRestoreScroll.ts
'use client';

import { useEffect } from 'react';

// Check the scroll position of the passed element id and restore it if it exists.
// Save the position to local storage on scroll.
export const useRestoreScrollPosition = ({
  elementId,
}: {
  elementId: string;
}) => {
  useEffect(() => {
    const element = document.getElementById(elementId);
    if (!element) return;

    const scrollPosition = localStorage.getItem(elementId);
    if (!scrollPosition) return;

    setTimeout(() => {
      element.scrollTop = parseInt(scrollPosition);
    }, 1);
  }, [elementId]);

  useEffect(() => {
    const element = document.getElementById(elementId);
    if (!element) return;

    const onScroll = () => {
      localStorage.setItem(elementId, element.scrollTop.toString());
    };

    element.addEventListener('scroll', onScroll);

    return () => {
      element.removeEventListener('scroll', onScroll);
    };
  }, [elementId]);

  return null;
};

We'll use these components in the next step, when we create the blog post hub layout component.

Create new layout & register it

Layouts are components that wrap blog posts. The Shipixen blog comes with a few layouts out of the box, but you can create your own.

The documentation hub at its core is a custom layout. We'll create a new layout component and register it.

layouts/PostHub.tsx
import { cn } from '@/lib/utils';
import { ReactNode } from 'react';
import { CoreContent } from '@shipixen/pliny/utils/contentlayer';
import type { Blog, Authors } from 'shipixen-contentlayer/generated';
import Link from '@/components/shared/Link';
import PageTitle from '@/components/shared/PageTitle';
import Tag from '@/components/shared/Tag';
import { siteConfig } from '@/data/config/site.settings';
import ScrollTop from '@/components/shared/ScrollTop';
import ActiveLink from '@/components/shared/ActiveLink';
import { TOC } from '@/components/blog/Toc';
import { ScrollRestoration } from '@/lib/ScrollRestoration';

const postDateTemplate: Intl.DateTimeFormatOptions = {
  weekday: 'long',
  year: 'numeric',
  month: 'long',
  day: 'numeric',
};

interface LayoutProps {
  className?: string;
  content: CoreContent<Blog>;
  authorDetails: CoreContent<Authors>[];
  next?: { path: string; title: string };
  prev?: { path: string; title: string };
  children: ReactNode;
}

export default function PostHubLayout({
  className,
  content,
  authorDetails,
  next,
  prev,
  children,
}: LayoutProps) {
  const { filePath, path, slug, date, lastmod, title, tags, toc } = content;
  const basePath = path.split('/')[0];

  return (
    <div
      className={cn(
        'w-full fancy-overlay fancy-overlay--muted flex flex-col flex-wrap lg:flex-row justify-center -mt-20 lg:-mt-32',
        className,
      )}
    >
      <aside
        className="lg:sticky max-h-screen top-0 p-6 w-full lg:w-[14rem] 2xl:w-full xl:max-w-[12rem] 2xl:max-w-[16rem] overflow-auto pb-12"
        id="navigation"
      >
        <ul className="toc-list">
          <li>
            <ActiveLink
              href="/docs/overview"
              className="toc-link fancy-link fancy-link--no-underline"
              activeClassName="toc-active-link"
            >
              <span>Overview</span>
            </ActiveLink>
          </li>

          <li className="toc-separator"></li>

          <li className="toc-title">
            <h3>Docs</h3>
          </li>

          <li>
            <ActiveLink
              href="/docs/get-started"
              className="toc-link fancy-link fancy-link--no-underline"
              activeClassName="toc-active-link"
            >
              <span>Getting Started</span>
            </ActiveLink>
          </li>
        </ul>
      </aside>

      <article className="w-full lg:max-w-3xl !p-0 !m-0 shadow-lg">
        <div className="bg-white dark:bg-slate-900 lg:px-10 lg:py-4 divide-y divide-gray-200 dark:divide-gray-700 xl:col-span-3 xl:row-span-2 xl:pb-0">
          <header className="px-6 pt-6 pb-6 lg:px-0" id="main">
            <div className="space-y-1">
              <div>
                <PageTitle>{title}</PageTitle>
              </div>

              <dl className="space-y-10">
                {lastmod ? (
                  <div>
                    <dt className="sr-only">Updated on</dt>
                    <dd className="text-base font-medium leading-6 text-gray-500 dark:text-gray-400">
                      <time dateTime={lastmod}>
                        <span className="font-light">Last updated</span>{' '}
                        {new Date(lastmod).toLocaleDateString(
                          siteConfig.locale,
                          postDateTemplate,
                        )}
                      </time>
                    </dd>
                  </div>
                ) : (
                  <div>
                    <dt className="sr-only">Published on</dt>
                    <dd className="text-base font-medium leading-6 text-gray-500 dark:text-gray-400">
                      <time dateTime={date}>
                        <span className="font-light">Published</span>{' '}
                        {new Date(date).toLocaleDateString(
                          siteConfig.locale,
                          postDateTemplate,
                        )}
                      </time>
                    </dd>
                  </div>
                )}
              </dl>
            </div>
          </header>

          <div className="prose max-w-none px-6 pb-8 pt-10 lg:px-0 dark:prose-invert overflow-hidden">
            {children}
          </div>
        </div>

        <footer className="p-6">
          <div className="divide-gray-200 text-sm font-medium leading-5 dark:divide-gray-700 xl:col-start-1 xl:row-start-2 xl:divide-y">
            {tags && (
              <div className="py-4 xl:py-8">
                <h2 className="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
                  Tags
                </h2>
                <div className="flex flex-wrap">
                  {tags.map((tag) => (
                    <Tag key={tag} text={tag} />
                  ))}
                </div>
              </div>
            )}
            {(next || prev) && (
              <div className="flex justify-between py-4 xl:block xl:space-y-8 xl:py-8">
                {prev && prev.path && (
                  <div>
                    <h2 className="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
                      Previous Article
                    </h2>
                    <div className="text-primary-500 hover:text-primary-600 dark:hover:text-primary-400">
                      <Link href={`/${prev.path}`}>{prev.title}</Link>
                    </div>
                  </div>
                )}
                {next && next.path && (
                  <div>
                    <h2 className="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
                      Next Article
                    </h2>
                    <div className="text-primary-500 hover:text-primary-600 dark:hover:text-primary-400">
                      <Link href={`/${next.path}`}>{next.title}</Link>
                    </div>
                  </div>
                )}
              </div>
            )}

            <div className="flex pt-4 xl:pt-8">
              <Link
                href={siteConfig.allArticlesPath}
                className="text-primary-500 hover:text-primary-600 dark:hover:text-primary-400"
                aria-label="More articles"
              >
                More articles &rarr;
              </Link>
            </div>
          </div>
        </footer>
      </article>

      <aside className="relative xl:sticky top-0 p-6 w-full xl:max-w-[16rem] xl:max-h-screen flex-col items-center overflow-auto pb-12 lg:max-w-xl">
        <h2 className="font-semibold text-sm">Jump to section</h2>
        <TOC toc={toc} className={cn('toc-list wide-container !p-0 mt-4')} />
      </aside>

      <ScrollRestoration elementId="navigation" />
      <ScrollTop />
    </div>
  );
}

Now, register the layout in app/[...slug].tsx:

app/[...slug].tsx
import 'css/prism.css';
import 'katex/dist/katex.css';

import PageTitle from '@/components/shared/PageTitle';
import { components } from '@/components/MDXComponents';
import { MDXLayoutRenderer } from '@shipixen/pliny/mdx-components';
import {
  sortPosts,
  coreContent,
  allCoreContent,
} from '@shipixen/pliny/utils/contentlayer';
import { allBlogs, allAuthors } from 'shipixen-contentlayer/generated';
import type { Authors, Blog } from 'shipixen-contentlayer/generated';
import PostSimple from '@/layouts/PostSimple';
import PostLayout from '@/layouts/PostLayout';
import PostBanner from '@/layouts/PostBanner';
+import PostHub from '@/layouts/PostHub';

import { Metadata } from 'next';
import { siteConfig } from '@/data/config/site.settings';

const BLOG_URL = siteConfig.blogPath ? `/${siteConfig.blogPath}` : '';

const defaultLayout = 'PostLayout';
const layouts = {
  PostSimple,
  PostLayout,
  PostBanner,
+ PostHub,
};

// ...

To note, the post hub layout component contains a few hardcoded links to the documentation articles. You can change those to your own articles and organize them as you see fit:

// ...

<ul className="toc-list">
  <li>
    <ActiveLink
      href="/docs/overview"
      className="toc-link fancy-link fancy-link--no-underline"
      activeClassName="toc-active-link"
    >
      <span>Overview</span>
    </ActiveLink>
  </li>

  <li className="toc-separator"></li>

  <li className="toc-title">
    <h3>Docs</h3>
  </li>

  <li>
    <ActiveLink
      href="/docs/get-started"
      className="toc-link fancy-link fancy-link--no-underline"
      activeClassName="toc-active-link"
    >
      <span>Getting Started</span>
    </ActiveLink>
  </li>
</ul>

// ...

We'll proceed to create those articles in the next step.

Add example articles

This part is similar to adding any other blog post in Shipixen.
You can create a Markdown file under data or in a subfolder of data e.g. data/docs/get-started.mdx.

In this example, we'll put the articles under data/docs/.
Create the following files:

data/docs/get-started.mdx
---
title: 'Get started'
date: '2023-12-01'
tags:
  - guide
  - documentation
summary: 'Get started with the documentation.'
layout: PostHub
---

Get started easily with the documentation.

## Summary

This shows how to get started with the documentation.
data/docs/overview.mdx
---
title: 'Overview'
date: '2023-12-03'
lastmod: '2023-12-03'
tags:
  - guide
  - documentation
summary: 'An overview of the documentation'
layout: PostHub
---

Overview of the documentation.

## Summary

This is the summary of the documentation. [Get started here](/docs/get-started).

Heads up! Remember to add the PostHub layout for each of those articles.

Now, if you navigate to e.g. /docs/get-started, you should see the documentation hub with the articles you just created:

Documentation hub