-
-
Save maciejpedzich/000da5c6b3a91290d49a91c9fe940ca3 to your computer and use it in GitHub Desktop.
--- | |
import type { CollectionEntry } from 'astro:content'; | |
import { getCollection, render } from 'astro:content'; | |
// @ - alias for "src" directory | |
import TableOfContents from '@/components/TableOfContents.astro'; | |
export async function getStaticPaths() { | |
const posts = await getCollection('YOUR_COLLECTION_NAME_HERE'); | |
return posts.map((post) => ({ | |
params: { slug: post.id }, | |
props: post, | |
})); | |
} | |
type Props = CollectionEntry<'YOUR_COLLECTION_NAME_HERE'>; | |
const post = Astro.props; | |
const { Content, headings } = await render(post); | |
--- | |
<h1>{post.data.title}</h1> | |
<TableOfContents headings={headings} /> | |
<article> | |
<Content /> | |
</article> |
--- | |
import type { MarkdownHeading } from 'astro'; | |
type Props = { | |
headings: MarkdownHeading[]; | |
}; | |
type HeadingWithSubheadings = MarkdownHeading & { | |
subheadings: MarkdownHeading[]; | |
}; | |
const { headings } = Astro.props; | |
const grouppedHeadings = headings.reduce((array, heading) => { | |
if (heading.depth === 2) { | |
array.push({ ...heading, subheadings: [] }); | |
} else if (heading.depth === 3) { | |
array.at(-1)?.subheadings.push(heading); | |
} | |
return array; | |
}, [] as HeadingWithSubheadings[]); | |
--- | |
<nav id="table-of-contents" aria-label="Table Of Contents"> | |
<ol> | |
{ | |
grouppedHeadings.map((h) => ( | |
<li> | |
<a href={`#${h.slug}`}>{h.text}</a> | |
{h.subheadings.length > 0 && ( | |
<ol> | |
{h.subheadings.map((sub) => ( | |
<li> | |
<a href={`#${sub.slug}`}>{sub.text}</a> | |
</li> | |
))} | |
</ol> | |
)} | |
</li> | |
)) | |
} | |
</ol> | |
</nav> | |
<script is:inline> | |
// This script tag is useful only if you want to display the TOC alongside the blog post... | |
// ... and highlight the section that the user is currently reading through. | |
// Feel free to remove this tag if you don't need this type of functionality. | |
const observer = new IntersectionObserver( | |
(entries) => { | |
for (const entry of entries) { | |
const headingFragment = `#${entry.target.id}`; | |
const tocItem = document.querySelector(`a[href="${headingFragment}"]`); | |
if (entry.isIntersecting) { | |
const previouslyActivatedItem = | |
document.querySelector('.active-toc-item'); | |
previouslyActivatedItem?.classList.remove('active-toc-item'); | |
tocItem.classList.add('active-toc-item'); | |
} else { | |
const isAnyOtherEntryIntersecting = entries.some( | |
(e) => e.target.id !== entry.target.id && e.isIntersecting | |
); | |
if (isAnyOtherEntryIntersecting) { | |
tocItem.classList.remove('active-toc-item'); | |
} | |
} | |
} | |
}, | |
{ root: null, rootMargin: '0px', threshold: [1] } | |
); | |
const sectionHeadings = document.querySelectorAll( | |
'article > h2, article > h3' | |
); | |
for (const heading of sectionHeadings) { | |
observer.observe(heading); | |
} | |
</script> | |
<style> | |
.active-toc-item { | |
font-weight: bold; | |
} | |
</style> |
Thanks, it works :)
Tip
You can also add this code at the end of the <script> for smooth scrolling to the section:
document.querySelectorAll('a[href^="#"]').forEach((anchor) => { anchor.addEventListener("click", function (e) { e.preventDefault(); document.querySelector(this.getAttribute("href")).scrollIntoView({ behavior: "smooth", }); }); });
Glad you like it, and thanks for the smooth scroll snippet!
I update the code as:
---
import type { MarkdownHeading } from 'astro'
import { generateToc } from '@/utils'
interface Props {
headings: MarkdownHeading[]
}
const { headings } = Astro.props
const toc = generateToc(headings)
---
<aside class='sticky top-20 order-2 -me-28 hidden basis-60 lg:flex lg:flex-col'>
<toc-heading>
<h2 class='font-semibold'>TABLE OF CONTENTS</h2>
<ul class='text-card-foreground'>
{toc.map((heading) => <li><a href='...' class='aria-selected:font-medium aria-selected:text-primary'>...</a></li> )}
</ul>
</toc-heading>
</aside>
<script>
class TOC extends HTMLElement {
headings!: HTMLElement[]
tocLinks!: HTMLAnchorElement[]
activeLink!: HTMLAnchorElement | undefined
constructor() {
super()
// initialize the headings and tocLinks
this.headings = Array.from(
document.querySelectorAll('article > #content > h2, article > #content > h3')
)
this.tocLinks = Array.from(this.querySelectorAll('a[href^="#"]'))
this.activeLink = undefined
}
updateActiveTOCItem = (entries: IntersectionObserverEntry[]) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue
// old link should be inactive
if (this.activeLink !== undefined) this.activeLink.ariaSelected = 'false'
// get new link and replace it
const newActiveLink = this.tocLinks.find(
(link) => `#${entry.target.id}` === link.getAttribute('href')
)
if (newActiveLink) newActiveLink.ariaSelected = 'true'
this.activeLink = newActiveLink
}
}
connectedCallback() {
// set observer
this.headings.forEach((heading) =>
new IntersectionObserver(this.updateActiveTOCItem, {
root: null,
rootMargin: '0% 0% -50% 0%',
threshold: [1]
}).observe(heading)
)
// smooth scroll
const self = this
this.tocLinks.forEach((anchor) => {
anchor.addEventListener('click', function (e) {
e.preventDefault()
const directHeading = self.headings.find(
(heading) => `#${heading.id}` === this.getAttribute('href')
)
directHeading?.scrollIntoView({
behavior: 'smooth'
})
})
})
}
}
customElements.define('toc-heading', TOC)
</script>
It might improve performance in repeatedly obtaining items. And make it into a web component can help to make code structures better. More ts & es features is support.
Thanks, it works :)
Tip
You can also add this code at the end of the <script> for smooth scrolling to the section:
document.querySelectorAll('a[href^="#"]').forEach((anchor) => { anchor.addEventListener("click", function (e) { e.preventDefault(); document.querySelector(this.getAttribute("href")).scrollIntoView({ behavior: "smooth", }); }); });
It's a cool solution but when you click it doesn't add the hash at the end of the URL.
You can add this line to fix it history.pushState(null, null, this.getAttribute("href"));
The final code looks like that :
document.querySelectorAll('a[href^="#"]').forEach((anchor) => {
anchor.addEventListener("click", function (e) {
e.preventDefault();
history.pushState(null, null, this.getAttribute("href"));
document.querySelector(this.getAttribute("href")).scrollIntoView({
behavior: "smooth",
});
});
});
@Delmotte-Vincent thanks for your reminds! It looks greater
Emmm... null is not allowed in the latest version of Astro. better code like:
connectedCallback() {
// Smooth scroll
this.tocLinks.forEach((link) => {
link.element.addEventListener('click', (e) => {
e.preventDefault()
// Push the history to add the hash at the end of the URL
const directHeading = this.headings.find((heading) => heading.id === link.slug)
if (directHeading) {
// Push the history to add the hash at the end of the URL
history.pushState(null, directHeading.textContent || "", this.getAttribute("href"));
directHeading.scrollIntoView({ behavior: 'smooth' });
} else {
console.warn(`No heading found for slug: ${link.slug}`);
}
})
})
// Initial first and listen to scroll event
setInterval(this.updatePositionAndStyle, 100)
window.addEventListener('scroll', this.updatePositionAndStyle)
}
which use textContent for param title
.
This needs a small update for Astro 5.0 which has changed the way that render works.
For [...slug].astro these are the changes. This is how I have it on my website.
import {
render,
CollectionEntry,
getCollection
} from "astro:content";
export async function getStaticPaths() {
const posts = await getCollection('YOUR_COLLECTION_NAME_HERE');
return posts.map((post) => ({
params: { slug: post.id},
props: entry,
}));
}
interface Props {
entry: CollectionEntry<"YOUR_COLLECTION_NAME_HERE">;
}
const { entry } = Astro.props;
const { Content, headings } = await render(entry);
This needs a small update for Astro 5.0 which has changed the way that render works.
For [...slug].astro these are the changes. This is how I have it on my website.
import { render, CollectionEntry, getCollection } from "astro:content"; export async function getStaticPaths() { const posts = await getCollection('YOUR_COLLECTION_NAME_HERE'); return posts.map((post) => ({ params: { slug: post.id}, props: entry, })); } interface Props { entry: CollectionEntry<"YOUR_COLLECTION_NAME_HERE">; } const { entry } = Astro.props; const { Content, headings } = await render(entry);
Thanks for the heads up! It's been a while since I've last updated this gist, so I'll try and do it tomorrow.
Edit: I've just updated the gist. Thanks once again for pointing out the breaking change.
Thanks, it works :)
Tip
You can also add this code at the end of the <script> for smooth scrolling to the section: