refactor: toc to remark chore: clean up types
refactor: toc to remark chore: clean up types
the3ash committed
Jul 11, 2025 at 14:54 UTC
620eabe3e587fe5bbffcb19832a4b19f1ce9d5ff
10 files changed
+221
-139
astro.config.ts
+2
-1
@@ -9,6 +9,7 @@ import remarkReadingTime from './src/plugins/remark-reading-time.mjs'
9
import rehypeCleanup from './src/plugins/rehype-cleanup.mjs'
10
import rehypeImageProcessor from './src/plugins/rehype-image-processor.mjs'
11
import rehypeCopyCode from './src/plugins/rehype-copy-code.mjs'
12
+import remarkTOC from './src/plugins/remark-toc.mjs'
13
import { SITE } from './src/config'
14
import path from 'path'
15
@@ -31,7 +32,7 @@ export default defineConfig({
32
theme: 'css-variables',
33
wrap: false
34
},
34
- remarkPlugins: [remarkMath, remarkDirective, remarkEmbeddedMedia, remarkReadingTime],
35
+ remarkPlugins: [remarkMath, remarkDirective, remarkEmbeddedMedia, remarkReadingTime, remarkTOC],
36
rehypePlugins: [rehypeKatex, rehypeCleanup, rehypeImageProcessor, rehypeCopyCode]
37
},
38
integrations: [mdx(), sitemap()],
src/components/ui/TableOfContents.astro
+85
-90
@@ -1,10 +1,37 @@
1
---
2
import { SITE } from '@/config'
3
+import type { TOCProps } from '@/types'
4
+
5
+const { toc = [] }: TOCProps = Astro.props
6
---
7
8
<div class="toc-container" id="toc">
9
<nav class="toc-nav">
7
- <ul class="toc-list" id="toc-list"></ul>
10
+ <ul class="toc-list" id="toc-list">
11
+ <!-- Back to top link -->
12
+ <li class="toc-item toc-level-0">
13
+ <a href="#" class="toc-link toc-title" title="Back to top" data-text="Back to top">
14
+ Back to top
15
+ </a>
16
+ </li>
17
+
18
+ <!-- TOC items -->
19
+ {
20
+ toc.map((item) => (
21
+ <li class={`toc-item toc-level-${item.level}`}>
22
+ <a
23
+ href={`#${item.id}`}
24
+ class="toc-link"
25
+ title={item.text}
26
+ data-text={item.text}
27
+ data-heading-id={item.id}
28
+ >
29
+ {item.text}
30
+ </a>
31
+ </li>
32
+ ))
33
+ }
34
+ </ul>
35
</nav>
36
</div>
37
@@ -17,6 +44,8 @@ import { SITE } from '@/config'
44
}}
45
>
46
;(function () {
47
+ let isInitialized = false
48
+
49
// TOC positioning logic (similar to BackButton)
50
function adjustTOCPosition() {
51
const tocContainer = document.querySelector('.toc-container')
@@ -51,98 +80,48 @@ import { SITE } from '@/config'
80
}
81
}
82
54
- // Extract headings and paragraphs and create TOC
55
- function createTOC() {
56
- const tocList = document.getElementById('toc-list')
57
- if (!tocList) return
58
-
59
- const tocItems = []
60
-
61
- // Get all content elements in document order
62
- const allElements = Array.from(document.querySelectorAll('h1, h2, h3'))
63
-
64
- // Process elements in document order
65
- allElements.forEach((element, index) => {
66
- if (element.tagName === 'H1' || element.tagName === 'H2' || element.tagName === 'H3') {
67
- // Skip the main title (first h1)
68
- if (index === 0 && element.tagName === 'H1') return
69
-
70
- const level = parseInt(element.tagName.charAt(1))
71
- if (level > 3) return // Only include h1, h2, h3
72
-
73
- const text = element.textContent || ''
74
- const id = element.id || `heading-${index}`
75
- element.id = id
76
-
77
- tocItems.push({
78
- level,
79
- text,
80
- id,
81
- element
82
- })
83
- }
84
- })
83
+ // Hide TOC if there are no headings (only title)
84
+ function checkTOCVisibility() {
85
+ const tocContainer = document.querySelector('.toc-container')
86
+ const tocItems = document.querySelectorAll('.toc-item:not(.toc-level-0)')
87
86
- // Hide TOC if there are no headings (only title)
87
- const tocContainerElement = document.querySelector('.toc-container')
88
- if (tocContainerElement && tocItems.length === 0) {
89
- tocContainerElement.style.display = 'none'
90
- return
88
+ if (tocContainer && tocItems.length === 0) {
89
+ tocContainer.style.display = 'none'
90
}
91
+ }
92
93
- // Clear existing TOC
94
- tocList.innerHTML = ''
95
-
96
- // Add title link at the top
97
- const titleLi = document.createElement('li')
98
- titleLi.className = 'toc-item toc-level-0'
93
+ // Add click handlers to TOC links using event delegation
94
+ function addTOCEventListeners() {
95
+ const tocContainer = document.querySelector('.toc-container')
96
+ if (!tocContainer) return
97
100
- const titleLink = document.createElement('a')
101
- titleLink.href = '#'
102
- titleLink.className = 'toc-link toc-title'
103
- titleLink.setAttribute('title', 'Back to top')
104
- titleLink.setAttribute('data-text', 'Back to top')
98
+ // Use event delegation for better performance and reliability
99
+ tocContainer.addEventListener('click', function (e) {
100
+ const link = e.target.closest('.toc-link')
101
+ if (!link) return
102
106
- // Add click handler to scroll to top
107
- titleLink.addEventListener('click', (e) => {
103
e.preventDefault()
109
- window.scrollTo({ top: 0, behavior: 'smooth' })
110
- // Update URL without page jump
111
- history.pushState(null, null, '#')
112
- })
104
114
- titleLi.appendChild(titleLink)
115
- tocList.appendChild(titleLi)
116
-
117
- // Create TOC items
118
- tocItems.forEach((item) => {
119
- const li = document.createElement('li')
120
- li.className = `toc-item toc-level-${item.level}`
121
-
122
- const link = document.createElement('a')
123
- link.href = `#${item.id}`
124
- link.className = 'toc-link'
125
- link.setAttribute('title', item.text) // Add title for accessibility
126
- link.setAttribute('data-text', item.text) // Add data-text for CSS content
127
- link.textContent = item.text // Add text content for hover display
128
-
129
- link.addEventListener('click', (e) => {
130
- e.preventDefault()
131
- const target = document.getElementById(item.id)
132
- if (target) {
133
- const rect = target.getBoundingClientRect()
134
- const scrollTop = window.pageYOffset || document.documentElement.scrollTop
135
- const offset = rect.top + scrollTop - 96 // 6rem = 96px
136
- window.scrollTo({ top: offset, behavior: 'smooth' })
137
- history.pushState(null, null, `#${item.id}`)
105
+ if (link.classList.contains('toc-title')) {
106
+ // Back to top
107
+ window.scrollTo({ top: 0, behavior: 'smooth' })
108
+ history.pushState(null, null, '#')
109
+ } else {
110
+ // TOC item
111
+ const href = link.getAttribute('href')
112
+ if (href && href.startsWith('#')) {
113
+ const targetId = href.substring(1)
114
+ const target = document.getElementById(targetId)
115
+ if (target) {
116
+ const rect = target.getBoundingClientRect()
117
+ const scrollTop = window.pageYOffset || document.documentElement.scrollTop
118
+ const offset = rect.top + scrollTop - 96 // 6rem = 96px
119
+ window.scrollTo({ top: offset, behavior: 'smooth' })
120
+ history.pushState(null, null, href)
121
+ }
122
}
139
- })
140
-
141
- li.appendChild(link)
142
- tocList.appendChild(li)
123
+ }
124
})
144
-
145
- updateActiveTOCItem()
125
}
126
127
// Update active TOC item based on scroll position
@@ -182,18 +161,33 @@ import { SITE } from '@/config'
161
}
162
163
function initTOC() {
164
+ if (isInitialized) return
165
+
166
adjustTOCPosition()
186
- createTOC()
167
+ checkTOCVisibility()
168
+ addTOCEventListeners()
169
+ updateActiveTOCItem() // Initial active state
170
+
171
+ isInitialized = true
172
}
173
189
- document.addEventListener('astro:page-load', () => {
174
+ // Initialize on page load
175
+ function handlePageLoad() {
176
initTOC()
191
- })
177
+ }
178
193
- document.addEventListener('DOMContentLoaded', () => {
194
- initTOC()
195
- })
179
+ // Listen for various page load events
180
+ document.addEventListener('astro:page-load', handlePageLoad)
181
+ document.addEventListener('DOMContentLoaded', handlePageLoad)
182
+
183
+ // Also initialize immediately if DOM is already loaded
184
+ if (document.readyState === 'loading') {
185
+ document.addEventListener('DOMContentLoaded', handlePageLoad)
186
+ } else {
187
+ handlePageLoad()
188
+ }
189
190
+ // Re-initialize on resize and scroll
191
window.addEventListener('resize', adjustTOCPosition)
192
window.addEventListener('scroll', updateActiveTOCItem)
193
})()
@@ -256,6 +250,7 @@ import { SITE } from '@/config'
250
color 0.2s ease-out,
251
font-size 0.2s ease-out,
252
text-indent 0.2s ease-out;
253
+ cursor: pointer;
254
}
255
256
.toc-link::after {
src/layouts/PostLayout.astro
+3
-12
@@ -1,6 +1,6 @@
1
---
2
import '@/styles/global.css'
3
-import type { CollectionEntry } from 'astro:content'
3
+import type { PostLayoutProps } from '@/types'
4
import FormattedDate from '@/components/features/FormattedDate.astro'
5
import FootnoteScroll from '@/components/features/FootnoteScroll.astro'
6
import BaseHead from '@/components/layout/BaseHead.astro'
@@ -16,16 +16,7 @@ import BaseLayout from '@/layouts/BaseLayout.astro'
16
17
import { SITE } from '@/config'
18
19
-type Props = CollectionEntry<'posts'>['data'] & {
20
- readingTime?: {
21
- text: string
22
- minutes: number
23
- time: number
24
- words: number
25
- }
26
-}
27
-
28
-const { title, pubDate, readingTime } = Astro.props
19
+const { title, pubDate, readingTime, toc } = Astro.props as PostLayoutProps
20
---
21
22
<BaseLayout title={`${title} · ${SITE.title}`} description={SITE.description} type="post">
@@ -35,7 +26,7 @@ const { title, pubDate, readingTime } = Astro.props
26
<div class="prose">
27
<GradientMask />
28
<BackButton />
38
- {SITE.toc && <TableOfContents />}
29
+ {SITE.toc && <TableOfContents toc={toc} />}
30
<div class="title">
31
<h1>{title}</h1>
32
<div class="date">
src/pages/[...slug].astro
+2
-1
@@ -18,8 +18,9 @@ const post = Astro.props
18
const { Content, remarkPluginFrontmatter } = await render(post)
19
20
const readingTime = remarkPluginFrontmatter.readingTime
21
+const toc = remarkPluginFrontmatter.toc || []
22
---
23
23
-<PostLayout {...post.data} readingTime={readingTime}>
24
+<PostLayout {...post.data} readingTime={readingTime} toc={toc}>
25
<Content />
26
</PostLayout>
src/plugins/remark-toc.mjs
new
+55
@@ -0,0 +1,55 @@
1
+import { visit } from 'unist-util-visit'
2
+
3
+export default function remarkTOC() {
4
+ return function (tree, file) {
5
+ const headings = []
6
+ let headingIndex = 0
7
+
8
+ // Extract headings from AST
9
+ visit(tree, 'heading', (node) => {
10
+ const level = node.depth
11
+
12
+ // Only process h1, h2, h3
13
+ if (level > 3) return
14
+
15
+ // Skip the first h1
16
+ if (level === 1 && headingIndex === 0) {
17
+ headingIndex++
18
+ return
19
+ }
20
+
21
+ const text = extractTextContent(node)
22
+ if (!text) return
23
+
24
+ const id = `heading-${headingIndex}`
25
+
26
+ if (!node.data) node.data = {}
27
+ if (!node.data.hProperties) node.data.hProperties = {}
28
+ node.data.hProperties.id = id
29
+
30
+ headings.push({
31
+ level,
32
+ text,
33
+ id,
34
+ index: headingIndex
35
+ })
36
+
37
+ headingIndex++
38
+ })
39
+
40
+ // Store TOC data in file.data.astro.frontmatter
41
+ if (!file.data.astro) file.data.astro = {}
42
+ if (!file.data.astro.frontmatter) file.data.astro.frontmatter = {}
43
+ file.data.astro.frontmatter.toc = headings
44
+ }
45
+}
46
+
47
+function extractTextContent(node) {
48
+ let text = ''
49
+
50
+ visit(node, 'text', (textNode) => {
51
+ text += textNode.value
52
+ })
53
+
54
+ return text.trim()
55
+}
src/types/component.types.ts
new
+27
@@ -0,0 +1,27 @@
1
+import type { TOCItem, ReadingTime } from './content.types'
2
+
3
+// TOC component props interface
4
+export interface TOCProps {
5
+ toc?: TOCItem[]
6
+}
7
+
8
+// Post layout props interface (generic, not tied to specific data source)
9
+export interface PostLayoutProps {
10
+ title: string
11
+ pubDate: Date
12
+ image?: string
13
+ readingTime?: ReadingTime
14
+ toc?: TOCItem[]
15
+}
16
+
17
+// Transition props interface
18
+export interface TransitionProps {
19
+ type: 'post' | 'page'
20
+ class?: string
21
+}
22
+
23
+// Layout props interface
24
+export interface LayoutProps extends TransitionProps {
25
+ title?: string
26
+ description?: string
27
+}
src/types/config.types.ts
new
+26
@@ -0,0 +1,26 @@
1
+// Date format types
2
+export type DateFormat =
3
+ | 'YYYY-MM-DD'
4
+ | 'MM-DD-YYYY'
5
+ | 'DD-MM-YYYY'
6
+ | 'MONTH DAY YYYY'
7
+ | 'DAY MONTH YYYY'
8
+
9
+// Site configuration type
10
+export interface SiteConfig {
11
+ website: string
12
+ title: string
13
+ author: string
14
+ description: string
15
+ language: string
16
+ contentWidth: string
17
+ centeredLayout: boolean
18
+ favicon: boolean
19
+ footer: boolean
20
+ fadeAnimation: boolean
21
+ dateFormat: DateFormat
22
+ dateSeparator: string
23
+ readingTime: boolean
24
+ imageViewer: boolean
25
+ copyCode: boolean
26
+}
src/types/content.types.ts
new
+15
@@ -0,0 +1,15 @@
1
+// Reading time interface
2
+export interface ReadingTime {
3
+ text: string
4
+ minutes: number
5
+ time: number
6
+ words: number
7
+}
8
+
9
+// TOC item interface
10
+export interface TOCItem {
11
+ level: number
12
+ text: string
13
+ id: string
14
+ index: number
15
+}
src/types/index.ts
+6
-26
@@ -1,28 +1,8 @@
1
-export * from './layout.types'
1
+// Configuration types
2
+export * from './config.types'
3
3
-// Date format types
4
-export type DateFormat =
5
- | 'YYYY-MM-DD'
6
- | 'MM-DD-YYYY'
7
- | 'DD-MM-YYYY'
8
- | 'MONTH DAY YYYY'
9
- | 'DAY MONTH YYYY'
4
+// Content types
5
+export * from './content.types'
6
11
-// Site configuration type
12
-export interface SiteConfig {
13
- website: string
14
- title: string
15
- author: string
16
- description: string
17
- language: string
18
- contentWidth: string
19
- centeredLayout: boolean
20
- favicon: boolean
21
- footer: boolean
22
- fadeAnimation: boolean
23
- dateFormat: DateFormat
24
- dateSeparator: string
25
- readingTime: boolean
26
- imageViewer: boolean
27
- copyCode: boolean
28
-}
7
+// Component types
8
+export * from './component.types'
src/types/layout.types.ts
deleted
-9
@@ -1,9 +0,0 @@
1
-export interface TransitionProps {
2
- type: 'post' | 'page'
3
- class?: string
4
-}
5
-
6
-export interface LayoutProps extends TransitionProps {
7
- title?: string
8
- description?: string
9
-}