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