Skip to content

Commit

Permalink
Merge pull request #1163 from neondatabase/toc-updates
Browse files Browse the repository at this point in the history
ToC updates for docs
  • Loading branch information
vannyle authored Nov 17, 2023
2 parents 80fa2d1 + 8b8f36e commit b13ea90
Show file tree
Hide file tree
Showing 7 changed files with 211 additions and 52 deletions.
6 changes: 5 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"@octokit/core": "^5.0.1",
"@radix-ui/react-dialog": "^1.0.4",
"@radix-ui/react-slider": "^1.1.2",
"@react-hook/throttle": "^2.2.0",
"@react-hook/window-scroll": "^1.3.0",
"@rive-app/react-canvas": "^4.1.4",
"@splinetool/react-spline": "^2.2.6",
Expand Down
3 changes: 3 additions & 0 deletions src/components/pages/doc/table-of-contents/item/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import Item from './item';

export default Item;
96 changes: 96 additions & 0 deletions src/components/pages/doc/table-of-contents/item/item.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import clsx from 'clsx';
import { AnimatePresence, LazyMotion, domAnimation, m } from 'framer-motion';
import PropTypes from 'prop-types';

const linkClassName =
'py-1.5 block text-sm leading-tight transition-colors duration-200 text-gray-new-40 hover:text-black-new dark:text-gray-new-90 dark:hover:text-white [&_code]:rounded-sm [&_code]:leading-none [&_code]:py-px [&_code]:bg-gray-new-94 [&_code]:px-1.5 [&_code]:font-mono [&_code]:font-normal dark:[&_code]:bg-gray-new-15';

const Item = ({ title, level, id, items, currentAnchor, isUserScrolling, setIsUserScrolling }) => {
const href = `#${id}`;
const shouldRenderSubItems =
!!items?.length &&
(currentAnchor === id || items.some(({ id }) => currentAnchor === id)) &&
isUserScrolling &&
level < 2; // render only 1 level of sub-items

const handleAnchorClick = (e, anchor) => {
e.preventDefault();
if (level === 1) {
setIsUserScrolling(false);
}

document.querySelector(anchor).scrollIntoView({
behavior: 'smooth',
block: 'start',
});

// changing hash without default jumps to anchor
// eslint-disable-next-line no-restricted-globals
if (history.pushState) {
// eslint-disable-next-line no-restricted-globals
history.pushState(false, false, anchor);
} else {
// old browser support
window.location.hash = anchor;
}

setTimeout(() => {
setIsUserScrolling(true);
}, 700);
};

return (
<LazyMotion features={domAnimation}>
<a
className={clsx(linkClassName, {
'text-black-new dark:text-white font-medium': currentAnchor === id,
})}
style={{
marginLeft: level === 1 ? '' : `${(level - 1) * 0.5}rem`,
}}
href={href}
dangerouslySetInnerHTML={{ __html: title }}
onClick={(e) => handleAnchorClick(e, href, id)}
/>
<AnimatePresence initial={false}>
{shouldRenderSubItems && (
<m.ul
initial={{ opacity: 0, maxHeight: 0 }}
animate={{ opacity: 1, maxHeight: 1000 }}
exit={{ opacity: 0, maxHeight: 0 }}
transition={{ duration: 0.2 }}
>
{items.map((item, index) => (
<li key={index}>
<Item
currentAnchor={currentAnchor}
isUserScrolling={isUserScrolling}
setIsUserScrolling={setIsUserScrolling}
{...item}
/>
</li>
))}
</m.ul>
)}
</AnimatePresence>
</LazyMotion>
);
};

Item.propTypes = {
title: PropTypes.string.isRequired,
level: PropTypes.number.isRequired,
items: PropTypes.arrayOf(
PropTypes.shape({
title: PropTypes.string.isRequired,
id: PropTypes.string.isRequired,
level: PropTypes.number.isRequired,
})
),
id: PropTypes.string.isRequired,
currentAnchor: PropTypes.string,
setIsUserScrolling: PropTypes.func.isRequired,
isUserScrolling: PropTypes.bool.isRequired,
};

export default Item;
96 changes: 63 additions & 33 deletions src/components/pages/doc/table-of-contents/table-of-contents.jsx
Original file line number Diff line number Diff line change
@@ -1,29 +1,66 @@
'use client';

import { useThrottleCallback } from '@react-hook/throttle';
import PropTypes from 'prop-types';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';

import TOCIcon from './images/toc.inline.svg';
import Item from './item';

const linkClassName =
'py-1.5 block text-sm leading-tight transition-colors duration-200 text-gray-new-40 hover:text-black-new dark:text-gray-new-90 dark:hover:text-white [&_code]:rounded-sm [&_code]:leading-none [&_code]:py-px [&_code]:bg-gray-new-94 [&_code]:px-1.5 [&_code]:font-mono [&_code]:font-normal dark:[&_code]:bg-gray-new-15';
const CURRENT_ANCHOR_GAP_PX = 100;

const TableOfContents = ({ items }) => {
const handleAnchorClick = (e, anchor) => {
e.preventDefault();
document.querySelector(anchor).scrollIntoView({
behavior: 'smooth',
block: 'start',
const titles = useRef([]);
const [currentAnchor, setCurrentAnchor] = useState(null);
const [isUserScrolling, setIsUserScrolling] = useState(true);

const flatItems = useMemo(
() =>
items.reduce((acc, item) => {
if (item.items) {
return [...acc, item, ...item.items];
}
return [...acc, item];
}, []),
[items]
);

useEffect(() => {
titles.current = flatItems
.map(({ id }) => document.getElementById(id))
.filter((anchor) => anchor !== null);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

const updateCurrentAnchor = useCallback(() => {
const currentTitleIdx = titles.current.findIndex((anchor) => {
const { top } = anchor.getBoundingClientRect();

return top - CURRENT_ANCHOR_GAP_PX >= 0;
});
// changing hash without default jumps to anchor
// eslint-disable-next-line no-restricted-globals
if (history.pushState) {
// eslint-disable-next-line no-restricted-globals
history.pushState(false, false, anchor);
} else {
// old browser support
window.location.hash = anchor;

const idx =
currentTitleIdx === -1 ? titles.current.length - 1 : Math.max(currentTitleIdx - 1, 0);

const currentTitle = titles.current[idx];

setCurrentAnchor(currentTitle.id);

if (isUserScrolling) {
// Open sub-items only if it's user-initiated scrolling
setCurrentAnchor(currentTitle.id);
}
};
}, [isUserScrolling]);

const onScroll = useThrottleCallback(updateCurrentAnchor, 100);

useEffect(() => {
updateCurrentAnchor();

window.addEventListener('scroll', onScroll);

return () => window.removeEventListener('scroll', onScroll);
}, [onScroll, updateCurrentAnchor]);

if (items.length === 0) return null;

Expand All @@ -34,23 +71,16 @@ const TableOfContents = ({ items }) => {
<span>On this page</span>
</h3>
<ul className="mt-2.5">
{items.map((item, index) => {
const linkHref = `#${item.id}`;
const { level } = item;
return (
<li key={index}>
<a
className={linkClassName}
style={{
marginLeft: level === 2 ? '' : `${(level - 2) * 0.5}rem`,
}}
href={linkHref}
dangerouslySetInnerHTML={{ __html: item.title }}
onClick={(e) => handleAnchorClick(e, linkHref)}
/>
</li>
);
})}
{items.map((item, index) => (
<li key={index}>
<Item
currentAnchor={currentAnchor}
isUserScrolling={isUserScrolling}
setIsUserScrolling={setIsUserScrolling}
{...item}
/>
</li>
))}
</ul>
</>
);
Expand Down
14 changes: 9 additions & 5 deletions src/components/shared/anchor-heading/anchor-heading.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,17 @@ const AnchorHeading =
(Tag) =>
// eslint-disable-next-line react/prop-types
({ children, className = null }) => {
const id = slugify(extractText(children), { lower: true, remove: /[*+~.()'"!?:@]/g }).replace(
/_/g,
''
);
const id = slugify(extractText(children), {
lower: true,
strict: true,
remove: /[*+~.()'"!:@]/g,
}).replace(/_/g, '');

return (
<Tag id={id} className={clsx('not-prose group relative w-fit lg:scroll-mt-5', className)}>
<Tag
id={id}
className={clsx('not-prose group relative w-fit scroll-mt-8 lg:scroll-mt-5', className)}
>
<a
className="anchor absolute -right-16 top-1/2 flex h-full -translate-x-full -translate-y-[calc(50%-0.15rem)] items-center justify-center px-2.5 no-underline opacity-0 transition-opacity duration-200 hover:opacity-100 group-hover:opacity-100 sm:hidden"
href={`#${id}`}
Expand Down
47 changes: 34 additions & 13 deletions src/utils/api-docs.js
Original file line number Diff line number Diff line change
Expand Up @@ -100,30 +100,51 @@ const getAllReleaseNotes = async () => {
.filter((item) => process.env.NEXT_PUBLIC_VERCEL_ENV !== 'production' || !item.isDraft);
};

const getTableOfContents = (content) => {
const headings = content.match(/(#+)\s(.*)/g) || [];
const arr = headings.map((item) => item.replace(/(#+)\s/, '$1 '));

const buildNestedToc = (headings, currentLevel) => {
const toc = [];

arr.forEach((item) => {
const [depth, title] = parseMDXHeading(item);

// replace mdx inline code with html inline code
while (headings.length > 0) {
const [depth, title] = parseMDXHeading(headings[0]);
const titleWithInlineCode = title.replace(/`([^`]+)`/g, '<code>$1</code>');

if (title && depth && depth <= 3) {
toc.push({
if (depth === currentLevel) {
const tocItem = {
title: titleWithInlineCode,
id: slugify(title, { lower: true, strict: true, remove: /[*+~.()'"!:@]/g }),
level: depth + 1,
});
level: depth,
};

headings.shift(); // remove the current heading

if (headings.length > 0 && parseMDXHeading(headings[0])[0] > currentLevel) {
tocItem.items = buildNestedToc(headings, currentLevel + 1);
}

toc.push(tocItem);
} else if (depth < currentLevel) {
// Return toc if heading is of shallower level
return toc;
} else {
// Skip headings of deeper levels
headings.shift();
}
});
}

return toc;
};

const getTableOfContents = (content) => {
const codeBlockRegex = /```[\s\S]*?```/g;
const headingRegex = /^(#+)\s(.*)$/gm;

const contentWithoutCodeBlocks = content.replace(codeBlockRegex, '');
const headings = contentWithoutCodeBlocks.match(headingRegex) || [];

const arr = headings.map((item) => item.replace(/(#+)\s/, '$1 '));

return buildNestedToc(arr, 1);
};

module.exports = {
getPostSlugs,
getPostBySlug,
Expand Down

1 comment on commit b13ea90

@vercel
Copy link

@vercel vercel bot commented on b13ea90 Nov 17, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.