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