feat(blog): add table of contents sidebar - #68
Conversation
Summary by BeetleThis PR enhances the blog and pages functionality by introducing a Table of Contents (TOC) sidebar for better navigation and readability. The changes involve migrating the markdown utilities from TypeScript to TSX to support React components, adding automatic heading ID generation for anchor links, and implementing a new sidebar component that displays the TOC for blog posts. This improves user experience by allowing readers to quickly navigate to specific sections within long-form content. 📁 File Changes Summary (Consolidated across all commits):
Total Changes: 4 files changed, +183 additions, -70 deletions 🗺️ Walkthrough:graph TD
A["Blog Post Markdown"] -->|"getTOC()"| B["Extract Headings"]
B -->|"Generate IDs"| C["TOC Items Array"]
C -->|"Render"| D["SideBar Component"]
D -->|"Display"| E["Table of Contents"]
A -->|"Render with Components"| F["headingComponents"]
F -->|"Add IDs to Headings"| G["Heading Elements"]
G -->|"Enable Anchor Links"| H["User Navigation"]
E -->|"Link to"| H
style A fill:#e1f5ff
style D fill:#fff3e0
style E fill:#f3e5f5
style H fill:#e8f5e9
🎯 Key Changes:
📊 Impact Assessment:
⚙️ SettingsSeverity Threshold: 📖 User Guide
|
| Table of contents | ||
| </span> | ||
| {/* toc */} | ||
| {toc?.map(async (item) => ( |
There was a problem hiding this comment.
The .map() callback uses the async keyword but doesn't actually await anything inside the callback. This is an anti-pattern that can cause unexpected behavior. The async keyword should be removed since the callback immediately returns JSX without any asynchronous operations.
Confidence: 5/5
Suggested Fix
| {toc?.map(async (item) => ( | |
| {toc?.map((item) => ( | |
The async keyword is unnecessary here because the callback doesn't perform any await operations. It immediately returns JSX. Removing async ensures the map function works as expected without creating unawaited promises.
Prompt for AI
Copy this prompt to your AI IDE to fix this issue locally:
In app/blog/[slug]/page.tsx around line 34, remove the `async` keyword from the .map() callback since the callback doesn't actually await anything and immediately returns JSX. This prevents potential race conditions and ensures the component renders correctly.
|
|
||
| if (firstChild?.type !== "text" || !firstChild.value) continue; | ||
|
|
||
| const match = firstChild.value.match(/^\[!([a-zA-Z0-9_-]+)\]\s*/); |
There was a problem hiding this comment.
The regex pattern on line 30 contains \s\* which matches whitespace followed by a literal asterisk character. This should be \s* (zero or more whitespace) to correctly match the alert syntax [!TYPE] followed by optional whitespace. The current pattern will fail to match valid alert blocks.
Confidence: 5/5
Suggested Fix
| const match = firstChild.value.match(/^\[!([a-zA-Z0-9_-]+)\]\s*/); | |
| const match = firstChild.value.match(/^\[!([a-zA-Z0-9_-]+)\]\s*/); | |
Change \s\* to \s* to match zero or more whitespace characters instead of requiring a literal asterisk. This ensures the regex correctly identifies alert blocks in the markdown.
Prompt for AI
Copy this prompt to your AI IDE to fix this issue locally:
In lib/markdown.tsx around line 30, fix the regex pattern from /^\[!([a-zA-Z0-9_-]+)\]\s\*/ to /^\[!([a-zA-Z0-9_-]+)\]\s*/ by changing \s\* to \s* so it correctly matches zero or more whitespace characters instead of a literal asterisk. This is critical for the alert syntax detection to work properly.
| const alertType = match[1].toLowerCase(); | ||
|
|
||
| firstChild.value = firstChild.value.replace( | ||
| /^\[![a-zA-Z0-9_-]+\]\s*/, |
There was a problem hiding this comment.
The regex pattern on line 37 also contains \s\* which should be \s*. This replacement pattern must match the same syntax as the detection pattern on line 30 to properly remove the alert marker from the text. The inconsistency will cause the alert marker to not be fully removed.
Confidence: 5/5
Suggested Fix
| /^\[![a-zA-Z0-9_-]+\]\s*/, | |
| /^\[![a-zA-Z0-9_-]+\]\s*/, | |
Change \s\* to \s* to match zero or more whitespace characters. This ensures the alert marker is completely removed from the beginning of the blockquote text.
Prompt for AI
Copy this prompt to your AI IDE to fix this issue locally:
In lib/markdown.tsx around line 37, fix the regex pattern from /^\[![a-zA-Z0-9_-]+\]\s\*/ to /^\[![a-zA-Z0-9_-]+\]\s*/ by changing \s\* to \s* so it correctly removes the alert marker including optional whitespace. This must match the detection pattern on line 30.
|
Note Linting checks passed successfully 🎉 All formatting and code quality checks are clean. You're good to merge 🚀 |
Summary by BeetleThis PR improves the responsive design of skeleton loaders across the website's blog and pages sections. The changes focus on making the UI more adaptive to different screen sizes by conditionally showing/hiding sidebar components based on viewport width. Additionally, a minor asset update is included for a user avatar. 📁 File Changes Summary (Consolidated across all commits):
Total Changes: 3 files changed, +20 additions, -8 deletions 🎯 Key Changes:
📊 Impact Assessment:
⚙️ SettingsSeverity Threshold: 📖 User Guide
|
|
✅ You're good to merge this PR! No issues found. Great job! Settings⚙️ SettingsSeverity Threshold: 📖 User Guide
|
|
Note Linting checks passed successfully 🎉 All formatting and code quality checks are clean. You're good to merge 🚀 |
Summary by BeetleThis PR enhances the blog and page reading experience by introducing a table of contents (TOC) sidebar with interactive navigation, adding action buttons for sharing and editing, and improving responsive design across different screen sizes. The changes involve refactoring markdown utilities to support heading ID generation, creating a new client-side copy-link component, and updating skeleton loaders to properly reflect the new sidebar layout. 📁 File Changes Summary (Consolidated across all commits):
Total Changes: 7 files changed, +174 additions, -95 deletions 🗺️ Walkthrough:graph TD
A["Blog Page Loads"] --> B["BlogPageContent Wrapper"]
B --> C["BlogContent Component"]
B --> D["SideBar Component"]
C --> E["Fetch Blog Content"]
E --> F["Render Markdown with headingComponents"]
F --> G["Headings Get Auto IDs via slugify"]
D --> H["Extract TOC from Content via getTOC"]
H --> I["Render TOC Links"]
I --> J["Link to Heading IDs"]
D --> K["Action Buttons Row"]
K --> L["Edit Button GitHub Link"]
K --> M["Share Button Twitter Intent"]
K --> N["Copy Link Button CopyLink Component"]
N --> O["Copy to Clipboard navigator.clipboard"]
P["Responsive Design"] --> Q["Desktop: Sidebar Visible"]
P --> R["Mobile: Sidebar Hidden lg:flex"]
style A fill:#e1f5ff
style G fill:#fff3e0
style H fill:#fff3e0
style O fill:#f3e5f5
style Q fill:#e8f5e9
style R fill:#e8f5e9
🎯 Key Changes:
📊 Impact Assessment:
⚙️ SettingsSeverity Threshold: 📖 User Guide
|
| <div className="sm:w-64 mt-xl py-sm hidden lg:flex flex-row overflow-x-auto w-full sm:flex-col gap-md shrink-0"> | ||
| {/* action row */} | ||
| <div className="flex gap-xs px-xs"> | ||
| {[ |
There was a problem hiding this comment.
The .map() callback uses async, which causes the map to return an array of Promises instead of JSX elements. React cannot render Promises, so the table of contents items will not appear in the UI.
Confidence: 5/5
Suggested Fix
| {[ | |
| {toc?.map((item) => ( | |
Remove the async keyword from the arrow function. The callback doesn't need to be async since it's only rendering JSX and not performing any async operations.
Prompt for AI
Copy this prompt to your AI IDE to fix this issue locally:
In app/blog/[slug]/page.tsx around line 34, the .map() callback incorrectly uses async, which causes it to return Promises instead of JSX elements that React can render. Remove the async keyword from the arrow function since no async operations are performed in the callback.
|
Note Linting checks passed successfully 🎉 All formatting and code quality checks are clean. You're good to merge 🚀 |
| const match = firstChild.value.match(/^\[!([a-zA-Z0-9_-]+)\]\s*/); | ||
|
|
||
| if (!match) continue; | ||
|
|
||
| const alertType = match[1].toLowerCase(); | ||
|
|
||
| firstChild.value = firstChild.value.replace( | ||
| /^\[![a-zA-Z0-9_-]+\]\s*/, |
There was a problem hiding this comment.
The regex patterns use \s\* which matches a literal space followed by an asterisk. This appears to be a typo - the intent is likely to match optional whitespace using \s* (zero or more whitespace characters). The current pattern requires exactly one space followed by a literal asterisk, which is overly restrictive and may not match the intended alert syntax variations.
Confidence: 4/5
Suggested Fix
| const match = firstChild.value.match(/^\[!([a-zA-Z0-9_-]+)\]\s*/); | |
| if (!match) continue; | |
| const alertType = match[1].toLowerCase(); | |
| firstChild.value = firstChild.value.replace( | |
| /^\[![a-zA-Z0-9_-]+\]\s*/, | |
| const match = firstChild.value.match(/^\[!([a-zA-Z0-9_-]+)\]\s*/); | |
| if (!match) continue; | |
| const alertType = match[1].toLowerCase(); | |
| firstChild.value = firstChild.value.replace( | |
| /^\[![a-zA-Z0-9_-]+\]\s*/, | |
| "", | |
| ); | |
Change \s\* to \s* in both regex patterns (lines 30 and 37). The \s* quantifier matches zero or more whitespace characters, which is the standard way to handle optional spacing after the alert type marker. This allows the pattern to match variations like [!NOTE]text or [!NOTE] text with different amounts of whitespace.
Prompt for AI
Copy this prompt to your AI IDE to fix this issue locally:
In lib/markdown.tsx around lines 30 and 37, the regex patterns incorrectly use \s\* which matches a literal space and asterisk. Change both occurrences to \s* to properly match zero or more whitespace characters after the alert type marker, allowing flexible spacing in the alert syntax.
📍 This suggestion applies to lines 30-37
| h1: ({ children, ...props }) => { | ||
| const id = slugify(String(children)); | ||
| return ( | ||
| <h1 id={id} {...props}> | ||
| {children} | ||
| </h1> | ||
| ); | ||
| }, | ||
| h2: ({ children, ...props }) => { | ||
| const id = slugify(String(children)); | ||
| return ( | ||
| <h2 id={id} {...props}> | ||
| {children} | ||
| </h2> | ||
| ); | ||
| }, | ||
| h3: ({ children, ...props }) => { | ||
| const id = slugify(String(children)); | ||
| return ( | ||
| <h3 id={id} {...props}> | ||
| {children} | ||
| </h3> | ||
| ); | ||
| }, | ||
| h4: ({ children, ...props }) => { | ||
| const id = slugify(String(children)); | ||
| return ( | ||
| <h4 id={id} {...props}> | ||
| {children} | ||
| </h4> | ||
| ); | ||
| }, | ||
| h5: ({ children, ...props }) => { | ||
| const id = slugify(String(children)); | ||
| return ( | ||
| <h5 id={id} {...props}> | ||
| {children} | ||
| </h5> | ||
| ); | ||
| }, | ||
| h6: ({ children, ...props }) => { | ||
| const id = slugify(String(children)); | ||
| return ( | ||
| <h6 id={id} {...props}> | ||
| {children} | ||
| </h6> | ||
| ); | ||
| }, |
There was a problem hiding this comment.
The slugify(String(children)) calls convert React children to strings using String(). If children is a React element, array, or object, this produces [object Object] or similar, resulting in invalid or colliding IDs. This breaks the table of contents functionality and anchor links.
Confidence: 4/5
Suggested Fix
The issue is that String(children) doesn't properly extract text from React elements. You need to recursively extract text content from children. Consider using a helper function:
| h1: ({ children, ...props }) => { | |
| const id = slugify(String(children)); | |
| return ( | |
| <h1 id={id} {...props}> | |
| {children} | |
| </h1> | |
| ); | |
| }, | |
| h2: ({ children, ...props }) => { | |
| const id = slugify(String(children)); | |
| return ( | |
| <h2 id={id} {...props}> | |
| {children} | |
| </h2> | |
| ); | |
| }, | |
| h3: ({ children, ...props }) => { | |
| const id = slugify(String(children)); | |
| return ( | |
| <h3 id={id} {...props}> | |
| {children} | |
| </h3> | |
| ); | |
| }, | |
| h4: ({ children, ...props }) => { | |
| const id = slugify(String(children)); | |
| return ( | |
| <h4 id={id} {...props}> | |
| {children} | |
| </h4> | |
| ); | |
| }, | |
| h5: ({ children, ...props }) => { | |
| const id = slugify(String(children)); | |
| return ( | |
| <h5 id={id} {...props}> | |
| {children} | |
| </h5> | |
| ); | |
| }, | |
| h6: ({ children, ...props }) => { | |
| const id = slugify(String(children)); | |
| return ( | |
| <h6 id={id} {...props}> | |
| {children} | |
| </h6> | |
| ); | |
| }, | |
| function extractTextFromChildren(children: any): string { | |
| if (typeof children === 'string') return children; | |
| if (typeof children === 'number') return String(children); | |
| if (Array.isArray(children)) { | |
| return children.map(extractTextFromChildren).join(''); | |
| } | |
| if (children?.props?.children) { | |
| return extractTextFromChildren(children.props.children); | |
| } | |
| return ''; | |
| } | |
| export const headingComponents: Components = { | |
| h1: ({ children, ...props }) => { | |
| const id = slugify(extractTextFromChildren(children)); | |
| return ( | |
| <h1 id={id} {...props}> | |
| {children} | |
| </h1> | |
| ); | |
| }, | |
| h2: ({ children, ...props }) => { | |
| const id = slugify(extractTextFromChildren(children)); | |
| return ( | |
| <h2 id={id} {...props}> | |
| {children} | |
| </h2> | |
| ); | |
| }, | |
| h3: ({ children, ...props }) => { | |
| const id = slugify(extractTextFromChildren(children)); | |
| return ( | |
| <h3 id={id} {...props}> | |
| {children} | |
| </h3> | |
| ); | |
| }, | |
| h4: ({ children, ...props }) => { | |
| const id = slugify(extractTextFromChildren(children)); | |
| return ( | |
| <h4 id={id} {...props}> | |
| {children} | |
| </h4> | |
| ); | |
| }, | |
| h5: ({ children, ...props }) => { | |
| const id = slugify(extractTextFromChildren(children)); | |
| return ( | |
| <h5 id={id} {...props}> | |
| {children} | |
| </h5> | |
| ); | |
| }, | |
| h6: ({ children, ...props }) => { | |
| const id = slugify(extractTextFromChildren(children)); | |
| return ( | |
| <h6 id={id} {...props}> | |
| {children} | |
| </h6> | |
| ); | |
| }, | |
| }; | |
Add the extractTextFromChildren helper function to properly extract text content from React children, handling strings, numbers, arrays, and nested React elements. This ensures valid, consistent IDs are generated for all heading types.
Prompt for AI
Copy this prompt to your AI IDE to fix this issue locally:
In lib/markdown.tsx, the heading components use String(children) which produces [object Object] for React elements. Create a helper function extractTextFromChildren() that recursively extracts text from React children (handling strings, numbers, arrays, and nested elements), then use it in all heading components (h1-h6) instead of String(children) to generate valid, consistent IDs for table of contents anchors.
📍 This suggestion applies to lines 76-123
| const handleCopy = () => { | ||
| navigator.clipboard.writeText(link); | ||
| }; |
There was a problem hiding this comment.
The navigator.clipboard.writeText() call is not awaited and has no error handling. If the clipboard operation fails (e.g., due to browser permissions or security restrictions), the user won't receive any feedback, and the failure will be silently ignored. This can lead to a poor user experience where users think the link was copied when it actually wasn't.
Confidence: 4/5
Suggested Fix
| const handleCopy = () => { | |
| navigator.clipboard.writeText(link); | |
| }; | |
| const handleCopy = async () => { | |
| try { | |
| await navigator.clipboard.writeText(link); | |
| // TODO: Add user feedback here (e.g., toast notification) | |
| } catch (error) { | |
| console.error("Failed to copy link:", error); | |
| // TODO: Show error feedback to user | |
| } | |
| }; | |
Make the handleCopy function async and properly await the clipboard operation. Wrap it in a try-catch block to handle potential errors. Consider adding user feedback (such as a toast notification) to confirm successful copy or inform the user of failures.
Prompt for AI
Copy this prompt to your AI IDE to fix this issue locally:
In app/blog/CopyLink.tsx around line 11, the navigator.clipboard.writeText() call is not awaited and lacks error handling. Make handleCopy async, await the clipboard operation, and wrap it in a try-catch block to handle failures gracefully. Consider adding user feedback (toast notification) to confirm the copy action succeeded or failed.
📍 This suggestion applies to lines 11-13
Summary by BeetleThis PR enhances the blog and page reading experience by introducing a table of contents (TOC) sidebar with interactive features. The changes span three commits that progressively build out the feature: first adding the core TOC functionality, then making the UI responsive across different screen sizes, and finally adding action buttons for sharing and copying links. The implementation includes markdown heading ID generation, a responsive sidebar layout, and client-side utilities for link copying. 📁 File Changes Summary (Consolidated across all commits):
Total Changes: 7 files changed, +160 additions, -140 deletions 🗺️ Walkthrough:graph TD
A["Blog Page Loaded"] --> B["BlogPageContent Component"]
B --> C["BlogContent Renders Markdown"]
B --> D["SideBar Component"]
C --> E["Markdown with headingComponents"]
E --> F["Headings get ID attributes"]
D --> G["getTOC Function Extracts Headings"]
G --> H["TOC Links with Anchors"]
D --> I["Action Row"]
I --> J["Edit Button GitHub Link"]
I --> K["Share Button Twitter Intent"]
I --> L["Copy Link Button CopyLink Component"]
L --> M["Clipboard API Copy URL"]
F --> N["Anchor Navigation to Headings"]
H --> N
🎯 Key Changes:
📊 Impact Assessment:
⚙️ SettingsSeverity Threshold: 📖 User Guide
|
| <div className="sm:w-64 mt-xl py-sm hidden lg:flex flex-row overflow-x-auto w-full sm:flex-col gap-md shrink-0"> | ||
| {/* action row */} | ||
| <div className="flex gap-xs px-xs"> | ||
| {[ |
There was a problem hiding this comment.
The .map() callback uses async, which causes the map to return an array of Promises instead of JSX elements. This will render [object Promise] in the UI instead of the actual Link components. Remove the async keyword from the callback - the component doesn't need to be async since toc is already computed synchronously above.
Confidence: 5/5
Suggested Fix
| {[ | |
| {toc?.map((item) => ( | |
The async keyword should be removed from the arrow function. The toc array is already available synchronously from line 26, so there's no need for an async callback. The map function should return JSX elements directly, not Promises.
Prompt for AI
Copy this prompt to your AI IDE to fix this issue locally:
In app/blog/[slug]/page.tsx around line 34, remove the `async` keyword from the map callback function. The toc array is already computed synchronously, so the callback doesn't need to be async. Change `{toc?.map(async (item) => (` to `{toc?.map((item) => (` to ensure the map returns JSX elements instead of Promises.
| const firstChild = firstParagraph.children?.[0]; | ||
|
|
||
| if (firstChild?.type !== "text" || !firstChild.value) continue; | ||
|
|
There was a problem hiding this comment.
The regex pattern /^\[!([a-zA-Z0-9_-]+)\]\s\*/ contains \s\* which matches a literal asterisk after whitespace. This appears to be unintended - it should likely be \s* (zero or more whitespace characters) to properly match the alert syntax like [!NOTE] or [!WARNING] . The current pattern requires a literal * character after the alert type, which won't match standard alert syntax.
Confidence: 4/5
Suggested Fix
| const match = firstChild.value.match(/^\[!([a-zA-Z0-9_-]+)\]\s*/); | |
Change \s\* to \s* to match zero or more whitespace characters instead of requiring a literal asterisk. This aligns with the standard GitHub-style alert syntax.
Prompt for AI
Copy this prompt to your AI IDE to fix this issue locally:
In lib/markdown.tsx around line 30, fix the regex pattern from /^\[!([a-zA-Z0-9_-]+)\]\s\*/ to /^\[!([a-zA-Z0-9_-]+)\]\s*/ by changing \s\* to \s*. The pattern should match zero or more whitespace characters, not a literal asterisk. Apply the same fix to line 37.
|
|
||
| const alertType = match[1].toLowerCase(); | ||
|
|
||
| firstChild.value = firstChild.value.replace( |
There was a problem hiding this comment.
Same issue as line 30: the regex pattern /^\[![a-zA-Z0-9_-]+\]\s\*/ uses \s\* which matches a literal asterisk. This should be \s* to match zero or more whitespace characters, consistent with the alert syntax pattern.
Confidence: 4/5
Suggested Fix
| firstChild.value = firstChild.value.replace( | |
| /^\[![a-zA-Z0-9_-]+\]\s*/, | |
Change \s\* to \s* to properly match the alert syntax pattern.
Prompt for AI
Copy this prompt to your AI IDE to fix this issue locally:
In lib/markdown.tsx around line 37, fix the regex pattern from /^\[![a-zA-Z0-9_-]+\]\s\*/ to /^\[![a-zA-Z0-9_-]+\]\s*/ by changing \s\* to \s*. This should match zero or more whitespace characters to properly remove the alert syntax marker.
|
Note Linting checks passed successfully 🎉 All formatting and code quality checks are clean. You're good to merge 🚀 |
| {[ | ||
| { | ||
| icon: Edit, | ||
| link: `${staticData.github}/website/edit/main/content/blog/${slug}.md`, |
There was a problem hiding this comment.
The code references staticData.github but staticData is not imported in this file. This will cause a runtime error: ReferenceError: staticData is not defined. The same issue occurs on line 58 with staticData.linkShort.
Confidence: 5/5
Suggested Fix
Add the missing import at the top of the file:
| link: `${staticData.github}/website/edit/main/content/blog/${slug}.md`, | |
| import staticData from "@/lib/staticdata"; | |
Then ensure staticData is imported before it's used in the component (around lines 37 and 58).
Prompt for AI
Copy this prompt to your AI IDE to fix this issue locally:
In app/blog/[slug]/page.tsx, add the missing import for staticData. At the top of the file with other imports, add: import staticData from "@/lib/staticdata"; This is needed because the SideBar component references staticData.github on line 37 and staticData.linkShort on line 58.
What does this PR do?
Adds table of contents to blog sidebar
Closes
Type of change(s)
Checklist
bun run lintornpm run lintcompletes without warnings or errorsbun run buildornpm run buildcompletes successfullyfeat: ...,fix: ...,chore: ...)console.log)Screenshots / recordings (if applicable)