Skip to content

Commit 0410c9e

Browse files
committed
feat: add TOC
1 parent f8bf9c8 commit 0410c9e

File tree

4 files changed

+200
-14
lines changed

4 files changed

+200
-14
lines changed

src/serve.ts

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,61 @@ async function generateSidebar(config: BunPressConfig, currentPath: string): Pro
3838
})
3939
}
4040

41+
/**
42+
* Extract headings from HTML content and generate page TOC
43+
*/
44+
async function generatePageTOC(html: string): Promise<string> {
45+
// Extract h2, h3, h4 headings from HTML
46+
const headingRegex = /<h([234])([^>]*)>(.*?)<\/h\1>/g
47+
const headings: Array<{ level: number, text: string, id: string }> = []
48+
49+
let match
50+
while ((match = headingRegex.exec(html)) !== null) {
51+
const level = Number.parseInt(match[1])
52+
const attributes = match[2]
53+
let text = match[3]
54+
55+
// Remove HTML tags from text
56+
text = text.replace(/<[^>]*>/g, '')
57+
58+
// Extract or generate ID
59+
const idMatch = attributes.match(/id="([^"]*)"/)
60+
let id = idMatch ? idMatch[1] : text.toLowerCase().replace(/\s+/g, '-').replace(/[^\w-]/g, '')
61+
62+
headings.push({ level, text, id })
63+
}
64+
65+
if (headings.length === 0) {
66+
return ''
67+
}
68+
69+
// Generate TOC items HTML
70+
const items = headings.map(heading => {
71+
const levelClass = heading.level > 2 ? `level-${heading.level}` : ''
72+
return `<a href="#${heading.id}" class="${levelClass}">${heading.text}</a>`
73+
}).join('\n ')
74+
75+
return await render('page-toc', { items })
76+
}
77+
78+
/**
79+
* Add IDs to headings in HTML content
80+
*/
81+
function addHeadingIds(html: string): string {
82+
return html.replace(/<h([234])([^>]*)>(.*?)<\/h\1>/g, (match, level, attributes, text) => {
83+
// Check if ID already exists
84+
if (attributes.includes('id=')) {
85+
return match
86+
}
87+
88+
// Generate ID from text
89+
const plainText = text.replace(/<[^>]*>/g, '')
90+
const id = plainText.toLowerCase().replace(/\s+/g, '-').replace(/[^\w-]/g, '')
91+
92+
return `<h${level}${attributes} id="${id}">${text}</h${level}>`
93+
})
94+
}
95+
4196
/**
4297
* Generate navigation HTML from BunPress config
4398
*/
@@ -98,14 +153,19 @@ async function wrapInLayout(content: string, config: BunPressConfig, currentPath
98153
}
99154

100155
// Documentation layout - with sidebar
156+
// Add IDs to headings and generate page TOC
157+
const contentWithIds = addHeadingIds(content)
158+
const pageTOC = await generatePageTOC(contentWithIds)
159+
101160
return await render('layout-doc', {
102161
title,
103162
description,
104163
meta,
105164
customCSS,
106165
nav: generateNav(config),
107166
sidebar: await generateSidebar(config, currentPath),
108-
content,
167+
content: contentWithIds,
168+
pageTOC,
109169
scripts,
110170
})
111171
}

src/templates/layout-doc.stx

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,12 @@
2020

2121
{{ sidebar }}
2222

23-
<main class="ml-[260px] mt-[60px] p-8 max-w-[900px]">
24-
<article class="prose prose-slate max-w-none
23+
<main class="ml-[260px] xl:mr-[256px] mt-[60px] p-8">
24+
<article class="prose prose-slate max-w-[784px]
2525
prose-h1:text-3xl prose-h1:mt-8 prose-h1:mb-4 prose-h1:pb-2 prose-h1:border-b prose-h1:border-[#e2e2e3]
26-
prose-h2:text-2xl prose-h2:mt-7 prose-h2:mb-3
27-
prose-h3:text-xl prose-h3:mt-6 prose-h3:mb-3
26+
prose-h2:text-2xl prose-h2:mt-7 prose-h2:mb-3 prose-h2:scroll-mt-24
27+
prose-h3:text-xl prose-h3:mt-6 prose-h3:mb-3 prose-h3:scroll-mt-24
28+
prose-h4:text-lg prose-h4:mt-5 prose-h4:mb-2 prose-h4:scroll-mt-24
2829
prose-p:my-4
2930
prose-a:text-[#3451b2] prose-a:no-underline hover:prose-a:underline
3031
prose-ul:my-4 prose-ul:pl-8
@@ -41,6 +42,8 @@
4142
</article>
4243
</main>
4344

45+
{{ pageTOC }}
46+
4447
{{ scripts }}
4548
</body>
4649
</html>

src/templates/page-toc.stx

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
<aside class="fixed top-[60px] right-0 bottom-0 w-[256px] overflow-y-auto py-8 px-4 hidden xl:block">
2+
<div class="text-sm">
3+
<p class="font-semibold text-[#213547] mb-4">On this page</p>
4+
<nav class="page-toc">
5+
{{ items }}
6+
</nav>
7+
</div>
8+
</aside>
9+
10+
<style>
11+
.page-toc a {
12+
display: block;
13+
padding: 4px 0 4px 16px;
14+
color: #476582;
15+
text-decoration: none;
16+
border-left: 1px solid #e2e2e3;
17+
transition: all 0.2s ease-in-out;
18+
font-size: 13px;
19+
line-height: 20px;
20+
}
21+
22+
.page-toc a:hover {
23+
color: #5672cd;
24+
}
25+
26+
.page-toc a.active {
27+
color: #5672cd;
28+
border-left-color: #5672cd;
29+
font-weight: 500;
30+
}
31+
32+
.page-toc a.level-3 {
33+
padding-left: 28px;
34+
}
35+
36+
.page-toc a.level-4 {
37+
padding-left: 40px;
38+
}
39+
</style>
40+
41+
<script>
42+
// Track active heading based on scroll position
43+
function initPageTOC() {
44+
const tocLinks = document.querySelectorAll('.page-toc a');
45+
const headings = Array.from(document.querySelectorAll('h2[id], h3[id], h4[id]'));
46+
47+
if (!tocLinks.length || !headings.length) return;
48+
49+
function updateActiveTOC() {
50+
const scrollY = window.scrollY + 100; // Offset for better UX
51+
52+
// Find the current heading
53+
let currentHeading = null;
54+
for (let i = headings.length - 1; i >= 0; i--) {
55+
if (headings[i].offsetTop <= scrollY) {
56+
currentHeading = headings[i];
57+
break;
58+
}
59+
}
60+
61+
// Update active states
62+
tocLinks.forEach(link => {
63+
const href = link.getAttribute('href');
64+
if (currentHeading && href === `#${currentHeading.id}`) {
65+
link.classList.add('active');
66+
} else {
67+
link.classList.remove('active');
68+
}
69+
});
70+
}
71+
72+
// Smooth scroll to anchor
73+
tocLinks.forEach(link => {
74+
link.addEventListener('click', (e) => {
75+
e.preventDefault();
76+
const targetId = link.getAttribute('href').substring(1);
77+
const targetElement = document.getElementById(targetId);
78+
if (targetElement) {
79+
targetElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
80+
}
81+
});
82+
});
83+
84+
// Listen to scroll events
85+
let ticking = false;
86+
window.addEventListener('scroll', () => {
87+
if (!ticking) {
88+
window.requestAnimationFrame(() => {
89+
updateActiveTOC();
90+
ticking = false;
91+
});
92+
ticking = true;
93+
}
94+
});
95+
96+
// Initial update
97+
updateActiveTOC();
98+
}
99+
100+
// Initialize when DOM is ready
101+
if (document.readyState === 'loading') {
102+
document.addEventListener('DOMContentLoaded', initPageTOC);
103+
} else {
104+
initPageTOC();
105+
}
106+
</script>

src/templates/sidebar-section.stx

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,40 @@
1-
<div class="mb-4">
1+
<div class="mb-4 sidebar-section">
22
<button
33
class="w-full flex items-center justify-between px-6 py-2 text-sm font-semibold text-[#213547] hover:text-[#5672cd] transition-colors cursor-pointer"
4-
onclick="this.parentElement.classList.toggle('collapsed'); this.querySelector('.chevron').classList.toggle('rotate-[-90deg]')"
4+
onclick="toggleSection(this.parentElement)"
55
>
66
<span>{{ title }}</span>
7-
<svg class="chevron w-4 h-4 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
7+
<svg class="chevron w-4 h-4 transition-transform duration-200 ease-in-out" fill="none" stroke="currentColor" viewBox="0 0 24 24">
88
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
99
</svg>
1010
</button>
11-
<ul class="list-none mt-1 sidebar-items">
12-
{{ items }}
13-
</ul>
11+
<div class="sidebar-items-wrapper">
12+
<ul class="list-none mt-1 sidebar-items">
13+
{{ items }}
14+
</ul>
15+
</div>
1416
</div>
1517

1618
<style>
17-
.collapsed .sidebar-items {
18-
display: none;
19+
.sidebar-items-wrapper {
20+
overflow: hidden;
21+
transition: max-height 0.3s ease-in-out, opacity 0.2s ease-in-out;
22+
max-height: 500px;
23+
opacity: 1;
1924
}
20-
.collapsed .chevron {
25+
26+
.sidebar-section.collapsed .sidebar-items-wrapper {
27+
max-height: 0;
28+
opacity: 0;
29+
}
30+
31+
.sidebar-section.collapsed .chevron {
2132
transform: rotate(-90deg);
2233
}
2334
</style>
35+
36+
<script>
37+
function toggleSection(section) {
38+
section.classList.toggle('collapsed');
39+
}
40+
</script>

0 commit comments

Comments
 (0)