Skip to content

feat(blog): add table of contents sidebar - #68

Merged
calebephrem merged 3 commits into
mainfrom
dev
Aug 29, 2026
Merged

feat(blog): add table of contents sidebar#68
calebephrem merged 3 commits into
mainfrom
dev

Conversation

@calebephrem

@calebephrem calebephrem commented Aug 29, 2026

Copy link
Copy Markdown
Member

What does this PR do?

Adds table of contents to blog sidebar

Closes

Type of change(s)

  • Bug fix
  • New feature
  • Documentation update
  • Style / UI change
  • Refactor (no functional change)
  • Performance improvement
  • Other

Checklist

  • I've tested this change locally and it works as expected
  • bun run lint or npm run lint completes without warnings or errors
  • bun run build or npm run build completes successfully
  • My commit messages follow Conventional Commits (e.g. feat: ..., fix: ..., chore: ...)
  • I've updated relevant documentation (README, comments, etc.) if needed
  • I've removed any temporary debugging code (e.g. console.log)

Screenshots / recordings (if applicable)

image

@devhub-bot devhub-bot Bot added the feat New feature label Aug 29, 2026
@beetle-ai

beetle-ai Bot commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Summary by Beetle

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

File Status Changes Description
app/blog/[slug]/page.tsx Modified +31/-4 Added SideBar component that displays table of contents for blog posts. Imports new getTOC and headingComponents utilities. Integrates sidebar into blog layout with responsive design (hidden on mobile, visible on lg screens). Increased max-width from 5xl to 6xl to accommodate sidebar.
app/pages/[slug]/page.tsx Modified +5/-2 Updated to use new headingComponents from markdown utilities to enable heading ID generation for anchor links in static pages.
lib/markdown.tslib/markdown.tsx Modified +147/-64 Migrated from TypeScript to TSX. Preserved existing remarkCustomAlerts plugin. Added headingComponents object that wraps h1-h6 elements with auto-generated slugified IDs. Added getTOC() function to extract headings from markdown and generate table of contents items with IDs and text. Added slugify() utility function and TocItem interface.

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
Loading

🎯 Key Changes:

  • Table of Contents Sidebar: New SideBar component displays a navigable list of headings from blog posts, positioned on the right side on larger screens and hidden on mobile for responsive design.
  • Automatic Heading ID Generation: Implemented headingComponents that automatically adds slugified IDs to all heading elements (h1-h6), enabling anchor link functionality without manual ID management.
  • TOC Extraction Logic: New getTOC() function parses markdown content using regex to extract h1 and h2 headings, generating structured TOC items with IDs and text for easy rendering.
  • Markdown Utilities Migration: Converted lib/markdown.ts to lib/markdown.tsx to support React component exports, maintaining backward compatibility with existing remarkCustomAlerts plugin while adding new functionality.
  • Layout Optimization: Increased blog layout max-width from max-w-5xl to max-w-6xl to accommodate the new sidebar without cramping content.
  • Consistent Heading Behavior: Applied headingComponents to both blog posts and static pages for consistent anchor link behavior across the site.

📊 Impact Assessment:

  • Security: No security implications. The changes are purely presentational and don't introduce new data handling or authentication logic. The slugify() function safely sanitizes heading text for use as HTML IDs.
  • Performance: Minimal performance impact. The getTOC() function uses a single regex pass over markdown content, which is negligible. The sidebar is rendered server-side and uses standard React components. No additional API calls or heavy computations introduced.
  • Maintainability: Improved maintainability through better code organization. The migration to TSX allows React components to be exported alongside utilities. The slugify() function is reusable and well-defined. The heading ID generation is now automatic, reducing manual maintenance burden. However, the regex-based TOC extraction is somewhat fragile and could break with unusual markdown formatting.
  • Testing: The PR lacks explicit test coverage for the new getTOC() and slugify() functions. These utility functions should have unit tests to ensure they handle edge cases (special characters, unicode, empty strings, etc.). The SideBar component should be tested for proper rendering and responsiveness. The headingComponents should be tested to verify ID generation works correctly across different heading levels.
⚙️ Settings

Severity Threshold: Medium — Balanced feedback — medium and high severity issues only.Change in Settings
Custom Rules: Define your own review rules — Set Custom Rules
PR Summary: Configure PR summary — Change in Settings

📖 User Guide
  • Once repos are connected, PR analysis is automatically enabled. You can disable analysis for this repo from beetleai.dev/analysis
  • Comment @beetle on any PR to start analysis manually
  • Comment @beetle stop to stop any ongoing analysis

Follow us: Beetle · X · LinkedIn

Comment thread app/blog/[slug]/page.tsx Outdated
Table of contents
</span>
{/* toc */}
{toc?.map(async (item) => (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Suggested change
{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.

Comment thread lib/markdown.tsx

if (firstChild?.type !== "text" || !firstChild.value) continue;

const match = firstChild.value.match(/^\[!([a-zA-Z0-9_-]+)\]\s*/);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Suggested change
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.

Comment thread lib/markdown.tsx
const alertType = match[1].toLowerCase();

firstChild.value = firstChild.value.replace(
/^\[![a-zA-Z0-9_-]+\]\s*/,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Suggested change
/^\[![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.

@devhub-bot

devhub-bot Bot commented Aug 29, 2026

Copy link
Copy Markdown

Note

Linting checks passed successfully 🎉

All formatting and code quality checks are clean.

You're good to merge 🚀

@beetle-ai

beetle-ai Bot commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Summary by Beetle

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

File Status Changes Description
app/blog/[slug]/page.tsx Modified +18/-6 Updated BlogSkeleton component to be responsive with a hidden sidebar on mobile (using hidden lg:flex). Refactored layout to use a flex container with proper width distribution. Extracted BlogPageContent wrapper component to improve code organization. Fixed async/await issue in table of contents mapping.
app/pages/[slug]/page.tsx Modified +1/-1 Updated PageSkeleton component to hide sidebar on small screens using hidden sm:flex utility class for better mobile responsiveness.
app/page.tsx Modified +1/-1 Updated user avatar URL from PNG to WebP format for improved performance and smaller file size.

Total Changes: 3 files changed, +20 additions, -8 deletions

🎯 Key Changes:

  • Responsive Skeleton Loaders: Both blog and pages skeletons now conditionally render sidebars based on screen size, improving mobile UX by preventing layout overflow on smaller devices.
  • Blog Layout Refactoring: The blog page now uses a proper flex layout with flex-4 and flex-1 proportions for content and sidebar, with a new BlogPageContent wrapper component for cleaner code organization.
  • Mobile-First Approach: Sidebars are hidden by default on mobile (hidden) and only shown on larger screens (lg:flex for blog, sm:flex for pages).
  • Asset Optimization: Avatar image converted from PNG to WebP format, reducing file size and improving page load performance.
  • Bug Fix: Removed async keyword from table of contents map function, which was causing potential issues with async iteration.

📊 Impact Assessment:

  • Security: No security implications. Changes are purely UI/UX related with no authentication, data handling, or vulnerability concerns.
  • Performance: Positive impact. WebP avatar format reduces image file size, improving page load times. Responsive skeleton loaders prevent unnecessary rendering of off-screen sidebar components on mobile devices, reducing DOM complexity and improving rendering performance on smaller screens.
  • Maintainability: Improved. The new BlogPageContent wrapper component provides better separation of concerns and makes the Suspense boundary clearer. The responsive skeleton approach is consistent across both blog and pages sections, establishing a pattern for future responsive components.
  • Testing: Minimal testing impact. Changes are primarily CSS utility class additions and component refactoring. Recommend testing:
  • Skeleton loader visibility at different breakpoints (mobile, tablet, desktop)
  • Blog and pages layout rendering on various screen sizes
  • Avatar image loads correctly in WebP format across browsers
⚙️ Settings

Severity Threshold: Medium — Balanced feedback — medium and high severity issues only.Change in Settings
Custom Rules: Define your own review rules — Set Custom Rules
PR Summary: Configure PR summary — Change in Settings

📖 User Guide
  • Once repos are connected, PR analysis is automatically enabled. You can disable analysis for this repo from beetleai.dev/analysis
  • Comment @beetle on any PR to start analysis manually
  • Comment @beetle stop to stop any ongoing analysis

Follow us: Beetle · X · LinkedIn

@beetle-ai

beetle-ai Bot commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

✅ You're good to merge this PR! No issues found. Great job!

Settings
⚙️ Settings

Severity Threshold: Medium — Balanced feedback — medium and high severity issues only.Change in Settings
Custom Rules: Define your own review rules — Set Custom Rules
PR Summary: Configure PR summary — Change in Settings

📖 User Guide
  • Once repos are connected, PR analysis is automatically enabled. You can disable analysis for this repo from beetleai.dev/analysis
  • Comment @beetle on any PR to start analysis manually
  • Comment @beetle stop to stop any ongoing analysis

@devhub-bot

devhub-bot Bot commented Aug 29, 2026

Copy link
Copy Markdown

Note

Linting checks passed successfully 🎉

All formatting and code quality checks are clean.

You're good to merge 🚀

@beetle-ai

beetle-ai Bot commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Summary by Beetle

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

File Status Changes Description
app/blog/[slug]/page.tsx Modified +100/-27 Added SideBar component with TOC navigation and action buttons (edit, share, copy link). Improved responsive skeleton loader to account for sidebar. Refactored to use BlogPageContent wrapper component. Fixed async/await issue in TOC mapping.
app/pages/[slug]/page.tsx Modified +6/-3 Imported headingComponents for heading ID generation. Updated markdown renderer to use custom heading components. Made page skeleton responsive with hidden sm:flex for sidebar visibility.
lib/markdown.tsx Added +147/-0 Converted from .ts to .tsx to support React components. Added headingComponents export with custom heading renderers that auto-generate slugified IDs for h1-h6 elements. Implemented getTOC() function to extract headings from markdown. Added slugify() utility and TocItem interface. Preserved existing remarkCustomAlerts plugin.
lib/markdown.ts Deleted +0/-64 Removed old TypeScript version (replaced by .tsx version with additional functionality).
app/blog/CopyLink.tsx Added +18/-0 New client-side component that wraps UI elements to add clipboard copy functionality. Uses React's cloneElement to inject onClick handler.
app/page.tsx Modified +1/-1 Updated avatar URL for "Li Productions" member from PNG to WebP format for better performance.
lib/staticdata.ts Modified +2/-0 Added two new static links: link (full devhub URL) and linkShort (shortened URL) for use in sharing functionality.

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
Loading

🎯 Key Changes:

  • Table of Contents Sidebar: New SideBar component automatically extracts headings from blog content and displays them as clickable navigation links with smooth scrolling via anchor IDs.
  • Heading ID Generation: Implemented headingComponents custom React Markdown renderer that automatically generates slugified IDs for all heading levels (h1-h6), enabling anchor-based navigation without manual ID management.
  • Action Buttons: Added three interactive buttons in the sidebar:
  • Edit: Links directly to the blog markdown file on GitHub for easy contributions
  • Share: Pre-fills Twitter intent with blog title, description, and shortened URL
  • Copy Link: Client-side component that copies the blog's shortened URL to clipboard
  • Responsive Sidebar: Sidebar is hidden on mobile/tablet (hidden lg:flex) and only visible on large screens, with improved skeleton loader that accounts for the sidebar layout.
  • Markdown Utilities Refactor: Migrated lib/markdown.ts to lib/markdown.tsx to support React components, consolidating heading rendering logic and TOC extraction in a single module.
  • Static Data Enhancement: Added shortened and full URLs to staticData for consistent link sharing across the application.
  • Bug Fix: Removed async keyword from TOC map function (was incorrectly declared as async).

📊 Impact Assessment:

  • Security:
  • ✅ No security vulnerabilities introduced. The CopyLink component safely uses navigator.clipboard API.
  • ✅ GitHub edit links use proper URL encoding for Twitter share intent.
  • ✅ No user input is directly rendered; all content is pre-generated from markdown.
  • Performance:
  • ✅ Minimal performance impact. TOC extraction happens server-side during page render.
  • ✅ Heading ID generation is lightweight (simple string slugification).
  • ✅ Sidebar is conditionally rendered only on large screens, reducing mobile bundle impact.
  • ⚠️ Consider: The getTOC() function uses regex on full markdown content; for very large blog posts, this could be optimized with memoization.
  • Maintainability:
  • ✅ Code is well-organized with clear separation of concerns (TOC extraction, heading rendering, action buttons).
  • slugify() utility is reusable and follows standard URL slug conventions.
  • ✅ Component structure is modular and easy to extend (e.g., adding more action buttons).
  • ⚠️ Consider: The SideBar component could be split into smaller sub-components (ActionRow, TOCList) for better testability.
  • Testing:
  • ⚠️ No test files added. Recommend adding tests for:
  • slugify() function with edge cases (special characters, unicode, empty strings)
  • getTOC() function with various markdown heading patterns
  • CopyLink component's clipboard functionality
  • Responsive behavior of sidebar on different screen sizes
  • ✅ Manual testing should verify anchor link navigation works correctly with generated IDs.
⚙️ Settings

Severity Threshold: Medium — Balanced feedback — medium and high severity issues only.Change in Settings
Custom Rules: Define your own review rules — Set Custom Rules
PR Summary: Configure PR summary — Change in Settings

📖 User Guide
  • Once repos are connected, PR analysis is automatically enabled. You can disable analysis for this repo from beetleai.dev/analysis
  • Comment @beetle on any PR to start analysis manually
  • Comment @beetle stop to stop any ongoing analysis

Follow us: Beetle · X · LinkedIn

Comment thread app/blog/[slug]/page.tsx
<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">
{[

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Suggested change
{[
{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.

@devhub-bot

devhub-bot Bot commented Aug 29, 2026

Copy link
Copy Markdown

Note

Linting checks passed successfully 🎉

All formatting and code quality checks are clean.

You're good to merge 🚀

Comment thread lib/markdown.tsx
Comment on lines +30 to +37
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*/,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Suggested change
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

Comment thread lib/markdown.tsx Outdated
Comment on lines +76 to +123
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>
);
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Suggested change
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

Comment thread app/blog/CopyLink.tsx Outdated
Comment on lines +11 to +13
const handleCopy = () => {
navigator.clipboard.writeText(link);
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Suggested change
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

@beetle-ai

beetle-ai Bot commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Summary by Beetle

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

File Status Changes Description
app/blog/[slug]/page.tsx Modified +100/-27 Added SideBar component with TOC and action buttons (Edit, Share, Copy Link). Refactored to use BlogPageContent wrapper. Updated BlogSkeleton to be responsive with proper flex layout for desktop/mobile. Fixed async/await issue in TOC mapping.
app/pages/[slug]/page.tsx Modified +6/-3 Imported headingComponents for markdown rendering. Updated PageSkeleton to hide sidebar on small screens with hidden sm:flex. Applied heading components to markdown renderer.
lib/markdown.tsx Modified +29/-46 Converted from .ts to .tsx to support React components. Added extractTextFromChildren() utility for robust text extraction from React nodes. Refactored heading components using createHeading() factory function for DRY code. Maintained remarkCustomAlerts functionality.
lib/markdown.ts Deleted +0/-64 Removed original TypeScript version (replaced by markdown.tsx).
app/blog/CopyLink.tsx Added +22/-0 New client component for clipboard functionality. Wraps children and injects onClick handler to copy links to clipboard.
app/page.tsx Modified +1/-1 Updated Li Productions avatar URL from PNG to WebP format for better performance.
lib/staticdata.ts Modified +2/-0 Added two new static links: link (full devhub URL) and linkShort (shortened URL) for use in sharing features.

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
Loading

🎯 Key Changes:

  • Table of Contents Sidebar: Added a responsive sidebar (hidden on mobile, visible on lg screens) that displays H1 and H2 headings extracted from blog content with anchor links for smooth navigation.
  • Heading ID Generation: Implemented slugify() function to convert heading text into URL-safe IDs. Refactored heading components using a factory pattern (createHeading()) to reduce code duplication and improve maintainability.
  • Action Row: Added three interactive buttons in the sidebar:
  • Edit: Links to GitHub to edit the blog post source
  • Share: Pre-fills Twitter intent with blog title, description, and shortened URL
  • Copy Link: Client-side component that copies the blog URL to clipboard using the Clipboard API
  • Responsive Design: Updated skeleton loaders to properly reflect the responsive layout. Blog skeleton now shows a 4:1 flex ratio for content vs sidebar on desktop, with sidebar hidden on mobile. Page skeleton hides sidebar on screens smaller than sm breakpoint.
  • Markdown Enhancement: Converted markdown utilities from TypeScript to TSX to support React components. Added robust extractTextFromChildren() utility to handle complex React node structures when generating heading IDs.
  • Static Data: Added shortened and full URLs to static data for consistent link sharing across the application.
  • Bug Fix: Removed async keyword from TOC map function (was incorrectly declared as async).

📊 Impact Assessment:

  • Security:
  • ✅ Clipboard API usage is safe with proper error handling
  • ✅ Twitter share intent uses URL encoding to prevent injection
  • ✅ GitHub edit links use static repository path (no user input in URL construction)
  • ⚠️ Ensure shortened URL service (dvx.vercel.app) is properly maintained and monitored
  • Performance:
  • ✅ TOC extraction happens server-side during page render (no client-side overhead)
  • ✅ Heading ID generation is efficient with simple regex-based slugification
  • ✅ Avatar format upgrade (PNG → WebP) reduces image payload
  • ⚠️ SideBar component fetches blog content again (potential duplicate data fetch - consider passing content as prop)
  • Maintainability:
  • ✅ Factory pattern for heading components reduces code duplication by ~40%
  • ✅ Clear separation of concerns: SideBar, BlogContent, CopyLink are distinct components
  • ✅ Utility functions (slugify, getTOC, extractTextFromChildren) are well-isolated
  • ⚠️ CopyLink component uses React.cloneElement which can be fragile; consider using context or render props pattern for better type safety
  • Testing:
  • ⚠️ No test files added for new utilities (slugify, getTOC, extractTextFromChildren)
  • ⚠️ CopyLink component lacks unit tests for clipboard functionality
  • ⚠️ Responsive layout changes should be tested across breakpoints (sm, lg)
  • 💡 Recommend adding tests for: heading ID generation edge cases, TOC extraction with various markdown formats, clipboard error handling
⚙️ Settings

Severity Threshold: Medium — Balanced feedback — medium and high severity issues only.Change in Settings
Custom Rules: Define your own review rules — Set Custom Rules
PR Summary: Configure PR summary — Change in Settings

📖 User Guide
  • Once repos are connected, PR analysis is automatically enabled. You can disable analysis for this repo from beetleai.dev/analysis
  • Comment @beetle on any PR to start analysis manually
  • Comment @beetle stop to stop any ongoing analysis

Follow us: Beetle · X · LinkedIn

Comment thread app/blog/[slug]/page.tsx
<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">
{[

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Suggested change
{[
{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.

Comment thread lib/markdown.tsx
const firstChild = firstParagraph.children?.[0];

if (firstChild?.type !== "text" || !firstChild.value) continue;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Suggested change
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.

Comment thread lib/markdown.tsx

const alertType = match[1].toLowerCase();

firstChild.value = firstChild.value.replace(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Suggested change
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.

@devhub-bot

devhub-bot Bot commented Aug 29, 2026

Copy link
Copy Markdown

Note

Linting checks passed successfully 🎉

All formatting and code quality checks are clean.

You're good to merge 🚀

Comment thread app/blog/[slug]/page.tsx
{[
{
icon: Edit,
link: `${staticData.github}/website/edit/main/content/blog/${slug}.md`,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Suggested change
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.

@calebephrem
calebephrem merged commit ad8624f into main Aug 29, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feat New feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add TOC to blog posts Update image link in testimonial section

1 participant