Skip to content

Commit

Permalink
feat(nx-dev): add table of content for documents
Browse files Browse the repository at this point in the history
  • Loading branch information
bcabanes committed Mar 28, 2023
1 parent 26c864a commit dc2502b
Show file tree
Hide file tree
Showing 4 changed files with 174 additions and 13 deletions.
33 changes: 25 additions & 8 deletions nx-dev/feature-doc-viewer/src/lib/doc-viewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import { Breadcrumbs, Footer } from '@nrwl/nx-dev/ui-common';
import { renderMarkdown } from '@nrwl/nx-dev/ui-markdoc';
import { NextSeo } from 'next-seo';
import { useRouter } from 'next/router';
import { useRef } from 'react';
import { collectHeadings, TableOfContents } from './table-of-contents';

export function DocViewer({
document,
Expand All @@ -17,10 +19,14 @@ export function DocViewer({
relatedDocuments: RelatedDocument[];
}): JSX.Element {
const router = useRouter();
const ref = useRef<HTMLDivElement | null>(null);

const { metadata, node } = renderMarkdown(document.content.toString(), {
filePath: document.filePath,
});
const { metadata, node, treeNode } = renderMarkdown(
document.content.toString(),
{
filePath: document.filePath,
}
);

const vm = {
title: metadata['title'] ?? document.name,
Expand All @@ -34,6 +40,7 @@ export function DocViewer({
filePath: '',
}
).node,
tableOfContent: collectHeadings(treeNode),
};

return (
Expand Down Expand Up @@ -75,11 +82,21 @@ export function DocViewer({
</div>
<div className="min-w-0 flex-auto pb-24 lg:pb-16">
{/*MAIN CONTENT*/}
<div
data-document="main"
className="prose prose-slate dark:prose-invert max-w-none"
>
{vm.content}
<div className="relative">
<div
ref={ref}
data-document="main"
className="prose prose-slate dark:prose-invert w-full max-w-none xl:max-w-2xl"
>
{vm.content}
</div>
<div className="fixed top-36 right-[max(4rem,calc(50%-45rem))] z-20 hidden w-60 overflow-y-auto bg-white py-10 text-sm xl:block">
<TableOfContents
elementRef={ref}
path={router.basePath}
headings={vm.tableOfContent}
/>
</div>
</div>
{/*RELATED CONTENT*/}
<div
Expand Down
90 changes: 90 additions & 0 deletions nx-dev/feature-doc-viewer/src/lib/table-of-contents.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import Link from 'next/link';
import { cx } from 'nx-dev/ui-primitives';
import { useHeadingsObserver } from './use-headings-observer';

interface Heading {
id: string;
level: number;
title: string;
}

export function collectHeadings(
node: any,
sections: Heading[] = []
): Heading[] {
if (node) {
if (node.name && node.name === 'Heading') {
const title = node.children[0];

if (typeof title === 'string') {
sections.push({
id: node.attributes['id'],
level: node.attributes['level'],
title,
});
}
}

if (node.children) {
for (const child of node.children) {
collectHeadings(child, sections);
}
}
}

return sections;
}

export function TableOfContents({
elementRef,
headings,
path,
}: {
elementRef: any;
headings: Heading[];
path: string;
}): JSX.Element {
const headingLevelTargets: number[] = [1, 2, 3]; // matching to: H1, H2, H3...
const items = headings.filter(
(item) => item.id && headingLevelTargets.includes(item.level)
);

const activeId = useHeadingsObserver(
elementRef,
{
threshold: [0, 0.25, 0.5, 0.75, 1],
root: null,
rootMargin: '-20% 0% -55% 0%',
},
headings.find((i) => i.level === 1)?.title || null
);

return (
<nav className="toc">
<span className="pl-4 font-medium">Quick reference</span>
{!!items.length ? (
<ul className="mt-4 flex-col">
{items.map((item) => {
const href = `${path}#${item.id}`;
return (
<li key={item.title}>
<Link
href={href}
className={cx(
'block w-full border-l-4 border-slate-50 py-1 pl-3 transition hover:border-slate-500 hover:underline',
{
'border-slate-500 bg-slate-50': activeId === item.id,
'pl-6': item.level === 3,
}
)}
>
{item.title}
</Link>
</li>
);
})}
</ul>
) : null}
</nav>
);
}
44 changes: 44 additions & 0 deletions nx-dev/feature-doc-viewer/src/lib/use-headings-observer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { RefObject, useEffect, useState } from 'react';

export function useHeadingsObserver(
elementRef: RefObject<Element>,
{ threshold = 0, root = null, rootMargin = '0%' }: IntersectionObserverInit,
cachekey: string | null = null
): string {
const [activeId, setActiveId] = useState<string>('');

useEffect(() => {
const handleObserver = (entries: IntersectionObserverEntry[]): void => {
for (const entry of entries) {
if (entry?.isIntersecting) setActiveId(entry.target.id);
}
};

const node = elementRef?.current; // DOM Ref
const hasIOSupport = !!window.IntersectionObserver;

if (!hasIOSupport || !node) return;
const observer = new IntersectionObserver(handleObserver, {
threshold,
root,
rootMargin,
});

const elements: NodeListOf<Element> = node.querySelectorAll('h1, h2, h3');
elements.forEach((e: Element) => {
observer.observe(e);
});

return () => observer.disconnect();

// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
elementRef?.current,
JSON.stringify(threshold),
root,
rootMargin,
cachekey,
]);

return activeId;
}
20 changes: 15 additions & 5 deletions nx-dev/ui-markdoc/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import { Node, parse, renderers, Tokenizer, transform } from '@markdoc/markdoc';
import {
Node,
parse,
RenderableTreeNode,
renderers,
Tokenizer,
transform,
} from '@markdoc/markdoc';
import { load as yamlLoad } from 'js-yaml';
import React, { ReactNode } from 'react';
import { Fence } from './lib/nodes/fence.component';
Expand Down Expand Up @@ -92,12 +99,14 @@ export const parseMarkdown: (markdown: string) => Node = (markdown) => {
export const renderMarkdown: (
documentContent: string,
options: { filePath: string }
) => { metadata: Record<string, any>; node: ReactNode } = (
documentContent: string,
options: { filePath: string } = { filePath: '' }
): { metadata: Record<string, any>; node: ReactNode } => {
) => {
metadata: Record<string, any>;
node: ReactNode;
treeNode: RenderableTreeNode;
} = (documentContent, options = { filePath: '' }) => {
const ast = parseMarkdown(documentContent);
const configuration = getMarkdocCustomConfig(options.filePath);
const treeNode = transform(ast, configuration.config);

return {
metadata: ast.attributes['frontmatter']
Expand All @@ -106,5 +115,6 @@ export const renderMarkdown: (
node: renderers.react(transform(ast, configuration.config), React, {
components: configuration.components,
}),
treeNode,
};
};

0 comments on commit dc2502b

Please sign in to comment.