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

@@ -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