Refactor TOC to use rehype-slug

- Rename package to `goodgo-docs`
- Add `rehype-slug` for automatic heading ID generation
- Simplify TableOfContents component by removing manual ID logic
This commit is contained in:
Ho Ngoc Hai
2026-01-08 10:19:26 +07:00
parent 4ccfb220be
commit 9884947953
5 changed files with 44 additions and 9093 deletions

View File

@@ -7,7 +7,7 @@ const withMDX = createMDX({
options: {
// Use string for Turbopack compatibility (Next.js 16)
remarkPlugins: ['remark-gfm'],
rehypePlugins: [],
rehypePlugins: ['rehype-slug'],
},
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
{
"name": "quaros-network-website",
"name": "goodgo-docs",
"version": "0.1.0",
"private": true,
"engines": {
@@ -22,6 +22,7 @@
"next-intl": "^4.5.5",
"react": "19.2.0",
"react-dom": "19.2.0",
"rehype-slug": "^6.0.0",
"remark-gfm": "^4.0.1",
"three": "^0.182.0"
},

View File

@@ -21,51 +21,19 @@ export default function TableOfContents({ locale }: TableOfContentsProps) {
// Extract headings from content
useEffect(() => {
const generateSlug = (text: string): string => {
return text
.toLowerCase()
.replace(/[^\w\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.trim();
};
const extractHeadings = (): HeadingItem[] => {
const content = document.querySelector('[data-docs-content]');
if (!content) return [];
const headingElements = content.querySelectorAll('h1, h2, h3, h4, h5, h6');
const headingItems: HeadingItem[] = [];
const usedIds = new Set<string>();
headingElements.forEach((heading) => {
const text = heading.textContent?.trim() || '';
let id = heading.id || heading.getAttribute('id') || '';
// If no ID exists, generate one from text
if (!id && text) {
id = generateSlug(text);
}
// Deduplicate ID
if (id) {
let uniqueId = id;
let counter = 1;
while (usedIds.has(uniqueId)) {
uniqueId = `${id}-${counter}`;
counter++;
}
id = uniqueId;
// Update the actual element ID so clicking TOC links works correctly
heading.id = id;
usedIds.add(id);
}
const level = parseInt(heading.tagName.charAt(1));
const id = heading.id;
if (id && text) {
const level = parseInt(heading.tagName.charAt(1));
headingItems.push({ id, text, level });
}
});
@@ -74,21 +42,19 @@ export default function TableOfContents({ locale }: TableOfContentsProps) {
};
// Function to update headings
const updateHeadings = (): boolean => {
const updateHeadings = () => {
const headingItems = extractHeadings();
setHeadings(headingItems);
// Reset active ID if we have no headings (e.g. page transition)
if (headingItems.length === 0) {
setActiveId('');
}
return headingItems.length > 0;
};
// Set up MutationObserver to watch for content changes
const content = document.querySelector('[data-docs-content]');
let observer: MutationObserver | null = null;
let timers: NodeJS.Timeout[] = [];
let foundHeadings = false;
const cleanup = () => {
if (observer) {
@@ -99,62 +65,31 @@ export default function TableOfContents({ locale }: TableOfContentsProps) {
timers = [];
};
// Function to try extracting with cleanup if successful
const tryExtractAndCleanup = () => {
if (updateHeadings() && !foundHeadings) {
foundHeadings = true;
// Keep observer running for dynamic updates, but stop timers
timers.forEach(t => clearTimeout(t));
timers = [];
}
};
// Try after a tiny delay to avoid synchronous setState in effect body (lint rule)
const initialTimer = setTimeout(() => {
tryExtractAndCleanup();
}, 0);
// Try extracting immediately
const initialTimer = setTimeout(updateHeadings, 0);
timers.push(initialTimer);
if (content) {
// Watch for changes in the content
// Watch for changes in the content structure (loading, hydration)
observer = new MutationObserver(() => {
tryExtractAndCleanup();
updateHeadings();
});
observer.observe(content, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['id'],
// No longer watching attributes since we don't modify them
});
}
// Also try periodically in case content loads later (especially for dynamic MDX imports)
const tryMultipleTimes = [100, 300, 500, 1000, 2000, 3000];
// Try periodically for dynamic content
const tryMultipleTimes = [100, 300, 500, 1000];
tryMultipleTimes.forEach((delay) => {
const timer = setTimeout(() => {
if (!foundHeadings) {
tryExtractAndCleanup();
}
}, delay);
const timer = setTimeout(updateHeadings, delay);
timers.push(timer);
});
// Also listen for route changes (Next.js navigation)
const handleRouteChange = () => {
foundHeadings = false;
// Reset and try again after a short delay
setTimeout(() => {
tryExtractAndCleanup();
}, 100);
};
window.addEventListener('popstate', handleRouteChange);
return () => {
cleanup();
window.removeEventListener('popstate', handleRouteChange);
};
return cleanup;
}, [pathname]); // Re-run when pathname changes
// Set up intersection observer for active heading

29
pnpm-lock.yaml generated
View File

@@ -234,6 +234,9 @@ importers:
react-dom:
specifier: 19.2.0
version: 19.2.0(react@19.2.0)
rehype-slug:
specifier: ^6.0.0
version: 6.0.0
remark-gfm:
specifier: ^4.0.1
version: 4.0.1
@@ -10500,6 +10503,10 @@ packages:
nypm: 0.6.2
pathe: 2.0.3
/github-slugger@2.0.0:
resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==}
dev: false
/glob-parent@5.1.2:
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
engines: {node: '>= 6'}
@@ -10649,6 +10656,12 @@ packages:
dependencies:
function-bind: 1.1.2
/hast-util-heading-rank@3.0.0:
resolution: {integrity: sha512-EJKb8oMUXVHcWZTDepnr+WNbfnXKFNf9duMesmr4S8SXTJBJ9M4Yok08pu9vxdJwdlGRhVumk9mEhkEvKGifwA==}
dependencies:
'@types/hast': 3.0.4
dev: false
/hast-util-to-estree@3.1.3:
resolution: {integrity: sha512-48+B/rJWAp0jamNbAAf9M7Uf//UVqAoMmgXhBdxTDJLGKY+LRnZ99qcG+Qjl5HfMpYNzS5v4EAwVEF34LeAj7w==}
dependencies:
@@ -10692,6 +10705,12 @@ packages:
transitivePeerDependencies:
- supports-color
/hast-util-to-string@3.0.1:
resolution: {integrity: sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A==}
dependencies:
'@types/hast': 3.0.4
dev: false
/hast-util-whitespace@3.0.0:
resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==}
dependencies:
@@ -14119,6 +14138,16 @@ packages:
transitivePeerDependencies:
- supports-color
/rehype-slug@6.0.0:
resolution: {integrity: sha512-lWyvf/jwu+oS5+hL5eClVd3hNdmwM1kAC0BUvEGD19pajQMIzcNUd/k9GsfQ+FfECvX+JE+e9/btsKH0EjJT6A==}
dependencies:
'@types/hast': 3.0.4
github-slugger: 2.0.0
hast-util-heading-rank: 3.0.0
hast-util-to-string: 3.0.1
unist-util-visit: 5.0.0
dev: false
/remark-gfm@4.0.1:
resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==}
dependencies: