Skip to content

Commit 18d04c8

Browse files
benbalterCopilot
andcommitted
Add dynamic sidebar table of contents with scroll-spy
Add a sticky right-sidebar TOC on xl: screens that highlights the active section as the reader scrolls, using IntersectionObserver. On smaller screens, the existing inline collapsible TOC is preserved. - Extract shared heading parsing into src/utils/toc.ts with tests - New TableOfContentsSidebar.astro with scroll-spy and accent border - PostLayout uses flex layout at xl: breakpoint for sidebar placement - Respects prefers-reduced-motion and hidden in print Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 86e04a2 commit 18d04c8

5 files changed

Lines changed: 319 additions & 29 deletions

File tree

src/components/TableOfContents.astro

Lines changed: 6 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -7,43 +7,23 @@
77
* <details> element — zero JavaScript, progressively enhanced.
88
*
99
* Only renders when there are 3+ headings (short posts don't need a TOC).
10+
* Hidden on xl: screens where the sidebar TOC is shown instead.
1011
*/
1112
13+
import { extractHeadings, MIN_HEADINGS_FOR_TOC } from '../utils/toc';
14+
1215
interface Props {
1316
/** Rendered HTML content of the post */
1417
content: string;
1518
}
1619
17-
interface TocEntry {
18-
id: string;
19-
text: string;
20-
level: number;
21-
}
22-
2320
const { content } = Astro.props;
24-
25-
// Extract h2 and h3 headings with their IDs from rendered HTML
26-
const headingRegex = /<h([23])\s+id="([^"]+)"[^>]*>(.*?)<\/h[23]>/gi;
27-
const headings: TocEntry[] = [];
28-
let match: RegExpExecArray | null;
29-
30-
while ((match = headingRegex.exec(content)) !== null) {
31-
// Strip HTML tags from heading text (e.g. anchor links from rehype-autolink-headings)
32-
const text = match[3].replace(/<[^>]+>/g, '').replace(/#$/, '').trim();
33-
if (text) {
34-
headings.push({
35-
level: parseInt(match[1]),
36-
id: match[2],
37-
text,
38-
});
39-
}
40-
}
41-
42-
const showToc = headings.length >= 3;
21+
const headings = extractHeadings(content);
22+
const showToc = headings.length >= MIN_HEADINGS_FOR_TOC;
4323
---
4424

4525
{showToc && (
46-
<details class="toc-container not-prose mb-8 rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
26+
<details class="toc-container not-prose mb-8 rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50 xl:hidden">
4727
<summary class="cursor-pointer select-none px-4 py-3 text-sm font-semibold text-gray-700 dark:text-gray-300 hover:text-primary dark:hover:text-primary-300 transition-colors duration-200">
4828
Table of contents
4929
</summary>
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
---
2+
/**
3+
* TableOfContentsSidebar
4+
*
5+
* Sticky right-sidebar table of contents with scroll-spy highlighting.
6+
* Visible only on xl: screens (≥1280px). Uses IntersectionObserver to
7+
* track which heading is currently in view and highlights it with an
8+
* accent left-border. Respects prefers-reduced-motion.
9+
*
10+
* Only renders when there are 3+ headings.
11+
*/
12+
13+
import { extractHeadings, MIN_HEADINGS_FOR_TOC } from '../utils/toc';
14+
15+
interface Props {
16+
/** Rendered HTML content of the post */
17+
content: string;
18+
}
19+
20+
const { content } = Astro.props;
21+
const headings = extractHeadings(content);
22+
const showToc = headings.length >= MIN_HEADINGS_FOR_TOC;
23+
---
24+
25+
{showToc && (
26+
<aside
27+
class="toc-sidebar hidden xl:block"
28+
aria-label="Table of contents"
29+
data-toc-sidebar
30+
>
31+
<nav>
32+
<p class="toc-sidebar-title">On this page</p>
33+
<ol class="toc-sidebar-list">
34+
{headings.map((h) => (
35+
<li>
36+
<a
37+
href={`#${h.id}`}
38+
class:list={['toc-sidebar-link', h.level === 3 && 'toc-sidebar-link--nested']}
39+
data-toc-href={h.id}
40+
>
41+
{h.text}
42+
</a>
43+
</li>
44+
))}
45+
</ol>
46+
</nav>
47+
</aside>
48+
)}
49+
50+
<script>
51+
function initTocSidebar() {
52+
const sidebar = document.querySelector('[data-toc-sidebar]');
53+
if (!sidebar) return;
54+
55+
const links = sidebar.querySelectorAll<HTMLAnchorElement>('[data-toc-href]');
56+
if (links.length === 0) return;
57+
58+
// Map heading IDs to their TOC link elements
59+
const linkMap = new Map<string, HTMLAnchorElement>();
60+
links.forEach((link) => {
61+
const id = link.dataset.tocHref;
62+
if (id) linkMap.set(id, link);
63+
});
64+
65+
// Track which headings are currently visible
66+
const visibleHeadings = new Set<string>();
67+
let orderedIds: string[] = [];
68+
69+
// Collect heading elements in DOM order
70+
linkMap.forEach((_, id) => {
71+
const el = document.getElementById(id);
72+
if (el) orderedIds.push(id);
73+
});
74+
75+
function setActive(id: string | null) {
76+
links.forEach((link) => link.removeAttribute('data-active'));
77+
if (id) {
78+
const activeLink = linkMap.get(id);
79+
if (activeLink) {
80+
activeLink.setAttribute('data-active', 'true');
81+
}
82+
}
83+
}
84+
85+
// Pick the topmost visible heading, or the last heading above viewport
86+
function updateActive() {
87+
if (visibleHeadings.size > 0) {
88+
// Find the first visible heading in DOM order
89+
for (const id of orderedIds) {
90+
if (visibleHeadings.has(id)) {
91+
setActive(id);
92+
return;
93+
}
94+
}
95+
}
96+
97+
// No heading is visible — find the last heading above the viewport
98+
let lastAbove: string | null = null;
99+
for (const id of orderedIds) {
100+
const el = document.getElementById(id);
101+
if (el && el.getBoundingClientRect().top < 100) {
102+
lastAbove = id;
103+
}
104+
}
105+
setActive(lastAbove);
106+
}
107+
108+
const observer = new IntersectionObserver(
109+
(entries) => {
110+
entries.forEach((entry) => {
111+
if (entry.isIntersecting) {
112+
visibleHeadings.add(entry.target.id);
113+
} else {
114+
visibleHeadings.delete(entry.target.id);
115+
}
116+
});
117+
updateActive();
118+
},
119+
{ rootMargin: '-80px 0px -60% 0px', threshold: 0 }
120+
);
121+
122+
orderedIds.forEach((id) => {
123+
const el = document.getElementById(id);
124+
if (el) observer.observe(el);
125+
});
126+
127+
// Initial highlight
128+
updateActive();
129+
}
130+
131+
initTocSidebar();
132+
</script>
133+
134+
<style>
135+
@reference "../styles/global.css";
136+
137+
.toc-sidebar {
138+
position: sticky;
139+
top: 6rem;
140+
align-self: start;
141+
max-height: calc(100vh - 8rem);
142+
overflow-y: auto;
143+
scrollbar-width: thin;
144+
scrollbar-color: var(--color-gray-300) transparent;
145+
width: 14rem;
146+
flex-shrink: 0;
147+
}
148+
149+
.toc-sidebar-title {
150+
@apply text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400;
151+
margin: 0 0 0.75rem 0;
152+
}
153+
154+
.toc-sidebar-list {
155+
@apply list-none m-0 p-0;
156+
}
157+
158+
.toc-sidebar-link {
159+
@apply block text-sm py-1.5 no-underline;
160+
@apply text-gray-500 dark:text-gray-400;
161+
border-left: 2px solid transparent;
162+
padding-left: 0.75rem;
163+
transition: color 0.15s ease, border-color 0.15s ease;
164+
}
165+
166+
.toc-sidebar-link:hover {
167+
@apply text-gray-800 dark:text-gray-200;
168+
}
169+
170+
.toc-sidebar-link[data-active="true"] {
171+
color: var(--color-primary);
172+
border-left-color: var(--color-primary);
173+
@apply font-medium;
174+
}
175+
176+
.toc-sidebar-link--nested {
177+
padding-left: 1.5rem;
178+
@apply text-xs;
179+
}
180+
181+
@media (prefers-color-scheme: dark) {
182+
.toc-sidebar {
183+
scrollbar-color: var(--color-gray-700) transparent;
184+
}
185+
186+
.toc-sidebar-link[data-active="true"] {
187+
color: var(--color-primary-300);
188+
border-left-color: var(--color-primary-300);
189+
}
190+
}
191+
192+
@media (prefers-reduced-motion: reduce) {
193+
.toc-sidebar-link {
194+
transition: none;
195+
}
196+
}
197+
198+
@media print {
199+
.toc-sidebar {
200+
display: none;
201+
}
202+
}
203+
</style>

src/layouts/PostLayout.astro

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import ArchivedPostWarning from '../components/ArchivedPostWarning.astro';
1919
import ShareButtons from '../components/ShareButtons.astro';
2020
import PostNavigation from '../components/PostNavigation.astro';
2121
import TableOfContents from '../components/TableOfContents.astro';
22+
import TableOfContentsSidebar from '../components/TableOfContentsSidebar.astro';
2223
import ReadingList from '../components/ReadingList.astro';
2324
import ReadingProgress from '../components/ReadingProgress.astro';
2425
import KeepReading from '../components/KeepReading.astro';
@@ -95,9 +96,9 @@ const blogPostingSchema = pubDate ? generateBlogPostingSchema({
9596
image={image}
9697
author={author}
9798
>
98-
<div class="max-w-4xl mx-auto px-4">
99-
<div>
100-
<article data-pagefind-body>
99+
<div class="max-w-4xl mx-auto px-4 xl:max-w-6xl">
100+
<div class="xl:flex xl:gap-8">
101+
<article data-pagefind-body class="xl:flex-1 xl:min-w-0 xl:max-w-4xl">
101102
<Breadcrumbs title={title} />
102103
<header class="mb-10">
103104
<h1 class="text-3xl md:text-4xl font-bold leading-tight text-gray-900 dark:text-gray-100">{title}</h1>
@@ -187,6 +188,8 @@ const blogPostingSchema = pubDate ? generateBlogPostingSchema({
187188
{blogPostingSchema && (
188189
<script type="application/ld+json" set:html={schemaToJsonLd(blogPostingSchema)} is:inline></script>
189190
)}
191+
192+
<TableOfContentsSidebar content={content} />
190193
</div>
191194
</div>
192195
</BaseLayout>

src/utils/toc.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { extractHeadings, MIN_HEADINGS_FOR_TOC } from './toc';
3+
4+
describe('extractHeadings', () => {
5+
it('extracts h2 and h3 headings with IDs', () => {
6+
const html = `
7+
<h2 id="intro">Introduction</h2>
8+
<p>Some content</p>
9+
<h3 id="details">Details</h3>
10+
<h2 id="conclusion">Conclusion</h2>
11+
`;
12+
expect(extractHeadings(html)).toEqual([
13+
{ level: 2, id: 'intro', text: 'Introduction' },
14+
{ level: 3, id: 'details', text: 'Details' },
15+
{ level: 2, id: 'conclusion', text: 'Conclusion' },
16+
]);
17+
});
18+
19+
it('strips nested HTML tags from heading text', () => {
20+
const html = '<h2 id="test"><a href="#test">Linked <strong>heading</strong></a></h2>';
21+
expect(extractHeadings(html)).toEqual([
22+
{ level: 2, id: 'test', text: 'Linked heading' },
23+
]);
24+
});
25+
26+
it('strips trailing # from heading text', () => {
27+
const html = '<h2 id="test"><a href="#test">Heading#</a></h2>';
28+
expect(extractHeadings(html)).toEqual([
29+
{ level: 2, id: 'test', text: 'Heading' },
30+
]);
31+
});
32+
33+
it('skips headings with empty text after stripping', () => {
34+
const html = '<h2 id="empty"><a href="#empty">#</a></h2>';
35+
expect(extractHeadings(html)).toEqual([]);
36+
});
37+
38+
it('ignores h1, h4, h5, h6 headings', () => {
39+
const html = `
40+
<h1 id="title">Title</h1>
41+
<h2 id="section">Section</h2>
42+
<h4 id="sub">Sub</h4>
43+
`;
44+
expect(extractHeadings(html)).toEqual([
45+
{ level: 2, id: 'section', text: 'Section' },
46+
]);
47+
});
48+
49+
it('returns empty array for content with no headings', () => {
50+
expect(extractHeadings('<p>No headings here</p>')).toEqual([]);
51+
});
52+
53+
it('handles headings with extra attributes', () => {
54+
const html = '<h2 id="test" class="text-lg" data-custom="true">Heading</h2>';
55+
expect(extractHeadings(html)).toEqual([
56+
{ level: 2, id: 'test', text: 'Heading' },
57+
]);
58+
});
59+
});
60+
61+
describe('MIN_HEADINGS_FOR_TOC', () => {
62+
it('is 3', () => {
63+
expect(MIN_HEADINGS_FOR_TOC).toBe(3);
64+
});
65+
});

src/utils/toc.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/**
2+
* Table of contents heading extraction utility.
3+
*
4+
* Parses rendered HTML to extract h2/h3 headings with their IDs,
5+
* used by both the inline and sidebar TOC components.
6+
*/
7+
8+
export interface TocEntry {
9+
id: string;
10+
text: string;
11+
level: number;
12+
}
13+
14+
/**
15+
* Extract h2 and h3 headings with their IDs from rendered HTML.
16+
* Strips nested HTML tags (e.g. anchor links from rehype-autolink-headings)
17+
* and trailing `#` characters.
18+
*/
19+
export function extractHeadings(html: string): TocEntry[] {
20+
const headingRegex = /<h([23])\s+id="([^"]+)"[^>]*>(.*?)<\/h[23]>/gi;
21+
const headings: TocEntry[] = [];
22+
let match: RegExpExecArray | null;
23+
24+
while ((match = headingRegex.exec(html)) !== null) {
25+
const text = match[3].replace(/<[^>]+>/g, '').replace(/#$/, '').trim();
26+
if (text) {
27+
headings.push({
28+
level: parseInt(match[1]),
29+
id: match[2],
30+
text,
31+
});
32+
}
33+
}
34+
35+
return headings;
36+
}
37+
38+
/** Minimum number of headings required to show a TOC. */
39+
export const MIN_HEADINGS_FOR_TOC = 3;

0 commit comments

Comments
 (0)