-
-
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.
Thank you so much πππ
Thank you so much πππ
You're welcome! Glad this gist is still helpful all these years later.
Thanks, it works :)
Tip
You can also add this code at the end of the <script> for smooth scrolling to the section: