feat(mobile): redesign menu with sidebar integration

- Move mobile nav controls to left side (hamburger, search, theme)
- Add sidebar TOC to mobile menu when page has sidebar layout
- Hide desktop sidebar on mobile since accessible via hamburger
- Standardize mobile menu typography with CSS variables
- Use font-family inherit for consistent fonts across menu elements
This commit is contained in:
Wayne Sutton
2025-12-23 14:01:37 -08:00
parent 3b9f140fe1
commit bdf5378a9a
17 changed files with 1447 additions and 106 deletions

16
TASK.md
View File

@@ -15,7 +15,7 @@
## Current Status
v1.24.0 deployed. Sidebar layout support for blog posts.
v1.24.2 deployed. Mobile menu redesigned with sidebar integration and typography standardization.
## Completed
@@ -158,6 +158,20 @@ v1.24.0 deployed. Sidebar layout support for blog posts.
- [x] Aggregate component registration in convex.config.ts
- [x] Stats query updated to use aggregate counts
- [x] Aggregate component documentation in prds/howstatsworks.md
- [x] Sidebar navigation anchor links fixed for collapsed/expanded sections
- [x] Navigation scroll calculation with proper header offset (80px)
- [x] Expand ancestors before scrolling to ensure target visibility
- [x] Removed auto-expand from scroll handler to preserve manual collapse state
- [x] Collapse button event handling improved to prevent link navigation
- [x] Heading extraction updated to filter out code blocks
- [x] Sidebar no longer shows example headings from markdown code examples
- [x] Mobile menu redesigned with left-aligned navigation controls
- [x] Hamburger menu order changed (hamburger, search, theme toggle)
- [x] Sidebar table of contents integrated into mobile menu
- [x] Desktop sidebar hidden on mobile when sidebar layout is enabled
- [x] SidebarContext created to share sidebar data between components
- [x] Mobile menu typography standardized with CSS variables
- [x] Font-family standardized using inherit for consistency
## Deployment Steps

View File

@@ -4,6 +4,53 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [1.24.2] - 2025-12-23
### Changed
- Mobile menu redesigned for better sidebar integration
- Mobile navigation controls moved to left side (hamburger, search, theme toggle)
- Hamburger menu order: hamburger first, then search, then theme toggle
- Sidebar table of contents now appears in mobile menu when page has sidebar layout
- Desktop sidebar hidden on mobile (max-width: 768px) since it's accessible via hamburger menu
- Back button and CopyPageDropdown remain visible above main content on mobile
- Mobile menu typography standardized
- All mobile menu elements now use CSS variables for font sizes
- Font-family standardized using `inherit` to match body font from global.css
- Mobile menu TOC links use consistent font sizing with desktop sidebar
- Added CSS variables: `--font-size-mobile-toc-title` and `--font-size-mobile-toc-link`
### Technical
- Updated `src/components/Layout.tsx`: Reordered mobile nav controls, added sidebar context integration
- Updated `src/components/MobileMenu.tsx`: Added sidebar headings rendering in mobile menu
- Updated `src/pages/Post.tsx`: Provides sidebar headings to context for mobile menu
- Updated `src/context/SidebarContext.tsx`: New context for sharing sidebar data between components
- Updated `src/styles/global.css`: Mobile menu positioning, sidebar hiding on mobile, font standardization
## [1.24.1] - 2025-12-23
### Fixed
- Sidebar navigation anchor links now work correctly when sections are collapsed or expanded
- Fixed navigation scroll calculation to use proper header offset (80px)
- Expand ancestors before scrolling to ensure target is visible
- Use requestAnimationFrame to ensure DOM updates complete before scrolling
- Removed auto-expand from scroll handler to prevent interfering with manual collapse/expand
- Collapse button now properly isolated from link clicks with event handlers
### Changed
- Updated `extractHeadings.ts` to filter out headings inside code blocks
- Prevents sidebar from showing example headings from markdown code examples
- Removes fenced code blocks (```) and indented code blocks before extracting headings
- Ensures sidebar only shows actual page headings, not code examples
### Technical
- Updated `src/components/PageSidebar.tsx`: Improved navigation logic and collapse button event handling
- Updated `src/utils/extractHeadings.ts`: Added `removeCodeBlocks` function to filter code before heading extraction
## [1.24.0] - 2025-12-23
### Added

View File

@@ -7,6 +7,7 @@ published: true
tags: ["configuration", "setup", "fork", "tutorial"]
readTime: "4 min read"
featured: true
layout: "sidebar"
featuredOrder: 0
authorName: "Markdown"
authorImage: "/images/authors/markdown.png"

View File

@@ -0,0 +1,250 @@
---
title: "Git commit message best practices"
description: "A guide to writing clear, consistent commit messages that help your team understand changes and generate better changelogs."
date: "2025-01-17"
slug: "git-commit-message-best-practices"
published: true
tags: ["git", "development", "best-practices", "workflow"]
readTime: "5 min read"
featured: false
authorName: "Markdown"
authorImage: "/images/authors/markdown.png"
excerpt: "Learn the Conventional Commits standard and write commit messages that make your project history clear and useful."
---
# Git commit message best practices
Good commit messages make project history readable. They help teammates understand changes, generate changelogs automatically, and make debugging easier. This guide covers the most common standard: Conventional Commits.
## Why commit messages matter
Commit messages document what changed and why. They serve as a project timeline. Clear messages help when:
- Reviewing code changes
- Debugging issues
- Generating release notes
- Onboarding new team members
- Understanding project evolution
Bad commit messages like "updates" or "fix" provide no context. Good messages explain the change and its purpose.
## The Conventional Commits standard
Conventional Commits is the most widely adopted format. It uses a simple structure:
```
<type>[optional scope]: <description>
[optional body]
[optional footer(s)]
```
The type and description are required. Everything else is optional.
## Commit types
Use these standard types:
| Type | When to use |
| ---------- | ----------------------------------------------- |
| `feat` | New feature |
| `fix` | Bug fix |
| `docs` | Documentation changes |
| `style` | Formatting, missing semicolons (no code change) |
| `refactor` | Code restructuring without changing behavior |
| `perf` | Performance improvements |
| `test` | Adding or updating tests |
| `chore` | Maintenance tasks, dependency updates |
| `ci` | CI/CD changes |
| `build` | Build system changes |
| `revert` | Reverting a previous commit |
## Writing good commit messages
### Subject line rules
Keep the subject line under 50 characters. Use imperative mood. Write "add feature" not "added feature" or "adds feature".
**Good examples:**
```
feat: add search functionality
fix: resolve write conflict in stats mutation
docs: update README with sync commands
refactor: simplify theme context logic
perf: optimize post query with index
```
**Bad examples:**
```
feature: updates
fix: bug
docs: changes
```
### Adding a body
For complex changes, add a body after a blank line. Explain what changed and why:
```
feat: add visitor tracking to stats page
Track active sessions using heartbeat system with 30-second
intervals. Sessions expire after 2 minutes of inactivity.
Uses event records pattern to prevent write conflicts.
Closes #123
```
### Using scopes
Scopes are optional but helpful for larger projects. They indicate which part of the codebase changed:
```
feat(api): add pagination to posts endpoint
fix(ui): resolve mobile menu closing issue
docs(readme): add deployment instructions
```
## Common patterns
### Feature additions
```
feat: add dark mode toggle
Implements theme switching with localStorage persistence.
Supports four themes: dark, light, tan, cloud.
```
### Bug fixes
```
fix: prevent duplicate heartbeat mutations
Adds 5-second debounce window using refs to prevent
overlapping calls. Resolves write conflicts in activeSessions.
```
### Documentation updates
```
docs: add Convex best practices section
Includes patterns for avoiding write conflicts, using
indexes, and making mutations idempotent.
```
### Refactoring
```
refactor: extract search logic into custom hook
Moves search state and handlers from component to
useSearch hook for better reusability.
```
## Benefits of consistent commits
### Automatic changelog generation
Tools like semantic-release read commit messages and generate changelogs automatically. Each `feat:` becomes a minor version bump. Each `fix:` becomes a patch.
### Better code review
Reviewers see the intent immediately. A commit titled "feat: add search modal" is clearer than "updates".
### Easier debugging
When investigating issues, clear commit messages help identify when bugs were introduced. Git blame becomes more useful.
### Team collaboration
Consistent messages create shared understanding. New team members learn the pattern quickly.
## Quick reference
**Format:**
```
<type>: <description>
```
**Types:** feat, fix, docs, style, refactor, perf, test, chore, ci, build, revert
**Rules:**
- Use lowercase for types
- Imperative mood ("add" not "added")
- Under 50 characters for subject
- No period at end of subject
- Add body for complex changes
**Examples:**
```
feat: add search functionality
fix: resolve write conflict in stats mutation
docs: update README with sync commands
refactor: simplify theme context logic
perf: optimize post query with index
chore: update dependencies
```
## Setting up commit templates
Create a git commit template to remind yourself of the format:
**Create `.gitmessage` in your project root:**
```
# <type>: <subject>
#
# <body>
#
# <footer>
```
**Configure git to use it:**
```bash
git config commit.template .gitmessage
```
Now when you run `git commit`, your editor opens with this template.
## Using commit hooks
Pre-commit hooks can validate commit messages automatically. Tools like commitlint check format before commits are accepted.
**Install commitlint:**
```bash
npm install --save-dev @commitlint/cli @commitlint/config-conventional
```
**Create `commitlint.config.js`:**
```javascript
module.exports = {
extends: ["@commitlint/config-conventional"],
};
```
**Add to `.husky/commit-msg`:**
```bash
npx --no -- commitlint --edit $1
```
This enforces the format on every commit.
## Summary
Good commit messages follow a simple pattern: type, colon, description. Use Conventional Commits for consistency. Keep subject lines under 50 characters. Use imperative mood. Add bodies for complex changes.
The format is simple but powerful. It makes project history readable, enables automatic tooling, and helps teams collaborate effectively.
Start with the basic format. Add scopes and bodies as your project grows. Consistency matters more than perfection.

View File

@@ -8,6 +8,52 @@ layout: "sidebar"
All notable changes to this project.
## v1.24.2
Released December 23, 2025
**Mobile menu redesign with sidebar integration**
- Mobile navigation controls moved to left side
- Hamburger menu, search, and theme toggle now positioned on the left
- Order: hamburger first, then search, then theme toggle
- Consistent left-aligned navigation on mobile devices
- Sidebar table of contents in mobile menu
- When a page or blog post has sidebar layout, the TOC appears in the mobile menu
- Desktop sidebar hidden on mobile (max-width: 768px) since accessible via hamburger
- Back button and CopyPageDropdown remain visible above main content on mobile
- Sidebar headings displayed with same collapsible tree structure as desktop
- Typography standardization
- All mobile menu elements use CSS variables for font sizes
- Font-family standardized using `inherit` to match body font
- Mobile menu TOC links use consistent sizing with desktop sidebar
- Added CSS variables: `--font-size-mobile-toc-title` and `--font-size-mobile-toc-link`
New files: `src/context/SidebarContext.tsx`
Updated files: `src/components/Layout.tsx`, `src/components/MobileMenu.tsx`, `src/pages/Post.tsx`, `src/styles/global.css`
## v1.24.1
Released December 23, 2025
**Sidebar navigation fixes**
- Fixed anchor link navigation when sidebar sections are collapsed or expanded
- Navigation now correctly scrolls to target headings with proper header offset
- Sections expand automatically when navigating to nested headings
- Collapse button works reliably without triggering navigation
- Manual collapse/expand state persists during scrolling
- Fixed heading extraction to ignore code blocks
- Sidebar no longer shows example headings from markdown code examples
- Only actual page headings appear in the table of contents
- Filters out fenced code blocks (```) and indented code blocks
Updated files: `src/components/PageSidebar.tsx`, `src/utils/extractHeadings.ts`
## v1.24.0
Released December 23, 2025

View File

@@ -49,7 +49,7 @@ A brief description of each file in the codebase.
| File | Description |
| ------------------------- | ---------------------------------------------------------- |
| `Layout.tsx` | Page wrapper with search button, theme toggle, mobile menu, and scroll-to-top |
| `Layout.tsx` | Page wrapper with search button, theme toggle, mobile menu (left-aligned on mobile), and scroll-to-top |
| `ThemeToggle.tsx` | Theme switcher (dark/light/tan/cloud) |
| `PostList.tsx` | Year-grouped blog post list or card grid (supports list/cards view modes) |
| `BlogPost.tsx` | Markdown renderer with syntax highlighting and collapsible sections (details/summary) |
@@ -57,16 +57,24 @@ A brief description of each file in the codebase.
| `SearchModal.tsx` | Full text search modal with keyboard navigation |
| `FeaturedCards.tsx` | Card grid for featured posts/pages with excerpts |
| `LogoMarquee.tsx` | Scrolling logo gallery with clickable links |
| `MobileMenu.tsx` | Slide-out drawer menu for mobile navigation with hamburger button |
| `MobileMenu.tsx` | Slide-out drawer menu for mobile navigation with hamburger button, includes sidebar table of contents when page has sidebar layout |
| `ScrollToTop.tsx` | Configurable scroll-to-top button with Phosphor ArrowUp icon |
| `GitHubContributions.tsx` | GitHub activity graph with theme-aware colors and year navigation |
| `VisitorMap.tsx` | Real-time visitor location map with dotted world display and theme-aware colors |
| `PageSidebar.tsx` | Collapsible table of contents sidebar for pages/posts with sidebar layout, extracts headings (H1-H6), active heading highlighting, smooth scroll navigation, localStorage persistence for expanded/collapsed state |
### Context (`src/context/`)
| File | Description |
| ------------------ | ---------------------------------------------------- |
| `ThemeContext.tsx` | Theme state management with localStorage persistence |
| `SidebarContext.tsx` | Shares sidebar headings and active ID between Post and Layout components for mobile menu integration |
### Utils (`src/utils/`)
| File | Description |
| --------------------- | -------------------------------------------------------------------- |
| `extractHeadings.ts` | Parses markdown content to extract headings (H1-H6), generates slugs, filters out headings inside code blocks |
### Hooks (`src/hooks/`)

View File

@@ -7,6 +7,44 @@ Date: 2025-12-23
All notable changes to this project.
## v1.24.2
Released December 23, 2025
**Mobile menu improvements**
- Mobile menu now includes search and theme toggle
- Header controls moved from top navigation to mobile menu drawer
- Easier access to search and theme switching on mobile devices
- Desktop navigation unchanged
- Sidebar table of contents in mobile menu
- When a page or post uses sidebar layout, the TOC appears in the mobile menu
- Page sidebar hidden on mobile to avoid duplication
- All sidebar features work: collapsible sections, active heading highlighting, smooth scroll navigation
- Sidebar remains visible on tablet for better UX
Updated files: `src/context/SidebarContext.tsx`, `src/components/MobileMenu.tsx`, `src/components/Layout.tsx`, `src/pages/Post.tsx`, `src/pages/Home.tsx`, `src/pages/Blog.tsx`, `src/pages/Stats.tsx`, `src/pages/Write.tsx`, `src/styles/global.css`
## v1.24.1
Released December 23, 2025
**Sidebar navigation fixes**
- Fixed anchor link navigation when sidebar sections are collapsed or expanded
- Navigation now correctly scrolls to target headings with proper header offset
- Sections expand automatically when navigating to nested headings
- Collapse button works reliably without triggering navigation
- Manual collapse/expand state persists during scrolling
- Fixed heading extraction to ignore code blocks
- Sidebar no longer shows example headings from markdown code examples
- Only actual page headings appear in the table of contents
- Filters out fenced code blocks (```) and indented code blocks
Updated files: `src/components/PageSidebar.tsx`, `src/utils/extractHeadings.ts`
## v1.24.0
Released December 23, 2025

View File

@@ -0,0 +1,247 @@
# Git commit message best practices
> A guide to writing clear, consistent commit messages that help your team understand changes and generate better changelogs.
---
Type: post
Date: 2025-01-17
Reading time: 5 min read
Tags: git, development, best-practices, workflow
---
# Git commit message best practices
Good commit messages make project history readable. They help teammates understand changes, generate changelogs automatically, and make debugging easier. This guide covers the most common standard: Conventional Commits.
## Why commit messages matter
Commit messages document what changed and why. They serve as a project timeline. Clear messages help when:
- Reviewing code changes
- Debugging issues
- Generating release notes
- Onboarding new team members
- Understanding project evolution
Bad commit messages like "updates" or "fix" provide no context. Good messages explain the change and its purpose.
## The Conventional Commits standard
Conventional Commits is the most widely adopted format. It uses a simple structure:
```
<type>[optional scope]: <description>
[optional body]
[optional footer(s)]
```
The type and description are required. Everything else is optional.
## Commit types
Use these standard types:
| Type | When to use |
| ---------- | ----------------------------------------------- |
| `feat` | New feature |
| `fix` | Bug fix |
| `docs` | Documentation changes |
| `style` | Formatting, missing semicolons (no code change) |
| `refactor` | Code restructuring without changing behavior |
| `perf` | Performance improvements |
| `test` | Adding or updating tests |
| `chore` | Maintenance tasks, dependency updates |
| `ci` | CI/CD changes |
| `build` | Build system changes |
| `revert` | Reverting a previous commit |
## Writing good commit messages
### Subject line rules
Keep the subject line under 50 characters. Use imperative mood. Write "add feature" not "added feature" or "adds feature".
**Good examples:**
```
feat: add search functionality
fix: resolve write conflict in stats mutation
docs: update README with sync commands
refactor: simplify theme context logic
perf: optimize post query with index
```
**Bad examples:**
```
feature: updates
fix: bug
docs: changes
```
### Adding a body
For complex changes, add a body after a blank line. Explain what changed and why:
```
feat: add visitor tracking to stats page
Track active sessions using heartbeat system with 30-second
intervals. Sessions expire after 2 minutes of inactivity.
Uses event records pattern to prevent write conflicts.
Closes #123
```
### Using scopes
Scopes are optional but helpful for larger projects. They indicate which part of the codebase changed:
```
feat(api): add pagination to posts endpoint
fix(ui): resolve mobile menu closing issue
docs(readme): add deployment instructions
```
## Common patterns
### Feature additions
```
feat: add dark mode toggle
Implements theme switching with localStorage persistence.
Supports four themes: dark, light, tan, cloud.
```
### Bug fixes
```
fix: prevent duplicate heartbeat mutations
Adds 5-second debounce window using refs to prevent
overlapping calls. Resolves write conflicts in activeSessions.
```
### Documentation updates
```
docs: add Convex best practices section
Includes patterns for avoiding write conflicts, using
indexes, and making mutations idempotent.
```
### Refactoring
```
refactor: extract search logic into custom hook
Moves search state and handlers from component to
useSearch hook for better reusability.
```
## Benefits of consistent commits
### Automatic changelog generation
Tools like semantic-release read commit messages and generate changelogs automatically. Each `feat:` becomes a minor version bump. Each `fix:` becomes a patch.
### Better code review
Reviewers see the intent immediately. A commit titled "feat: add search modal" is clearer than "updates".
### Easier debugging
When investigating issues, clear commit messages help identify when bugs were introduced. Git blame becomes more useful.
### Team collaboration
Consistent messages create shared understanding. New team members learn the pattern quickly.
## Quick reference
**Format:**
```
<type>: <description>
```
**Types:** feat, fix, docs, style, refactor, perf, test, chore, ci, build, revert
**Rules:**
- Use lowercase for types
- Imperative mood ("add" not "added")
- Under 50 characters for subject
- No period at end of subject
- Add body for complex changes
**Examples:**
```
feat: add search functionality
fix: resolve write conflict in stats mutation
docs: update README with sync commands
refactor: simplify theme context logic
perf: optimize post query with index
chore: update dependencies
```
## Setting up commit templates
Create a git commit template to remind yourself of the format:
**Create `.gitmessage` in your project root:**
```
# <type>: <subject>
#
# <body>
#
# <footer>
```
**Configure git to use it:**
```bash
git config commit.template .gitmessage
```
Now when you run `git commit`, your editor opens with this template.
## Using commit hooks
Pre-commit hooks can validate commit messages automatically. Tools like commitlint check format before commits are accepted.
**Install commitlint:**
```bash
npm install --save-dev @commitlint/cli @commitlint/config-conventional
```
**Create `commitlint.config.js`:**
```javascript
module.exports = {
extends: ["@commitlint/config-conventional"],
};
```
**Add to `.husky/commit-msg`:**
```bash
npx --no -- commitlint --edit $1
```
This enforces the format on every commit.
## Summary
Good commit messages follow a simple pattern: type, colon, description. Use Conventional Commits for consistency. Keep subject lines under 50 characters. Use imperative mood. Add bodies for complex changes.
The format is simple but powerful. It makes project history readable, enables automatic tooling, and helps teams collaborate effectively.
Start with the basic format. Add scopes and bodies as your project grows. Consistency matters more than perfection.

View File

@@ -6,6 +6,7 @@ import Blog from "./pages/Blog";
import Write from "./pages/Write";
import Layout from "./components/Layout";
import { usePageTracking } from "./hooks/usePageTracking";
import { SidebarProvider } from "./context/SidebarContext";
import siteConfig from "./config/siteConfig";
function App() {
@@ -19,18 +20,20 @@ function App() {
}
return (
<Layout>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/stats" element={<Stats />} />
{/* Blog page route - only enabled when blogPage.enabled is true */}
{siteConfig.blogPage.enabled && (
<Route path="/blog" element={<Blog />} />
)}
{/* Catch-all for post/page slugs - must be last */}
<Route path="/:slug" element={<Post />} />
</Routes>
</Layout>
<SidebarProvider>
<Layout>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/stats" element={<Stats />} />
{/* Blog page route - only enabled when blogPage.enabled is true */}
{siteConfig.blogPage.enabled && (
<Route path="/blog" element={<Blog />} />
)}
{/* Catch-all for post/page slugs - must be last */}
<Route path="/:slug" element={<Post />} />
</Routes>
</Layout>
</SidebarProvider>
);
}

View File

@@ -405,6 +405,14 @@ export default function BlogPost({ content }: BlogPostProps) {
</h5>
);
},
h6({ children }) {
const id = generateSlug(getTextContent(children));
return (
<h6 id={id} className="blog-h6">
{children}
</h6>
);
},
ul({ children }) {
return <ul className="blog-ul">{children}</ul>;
},

View File

@@ -7,6 +7,7 @@ import ThemeToggle from "./ThemeToggle";
import SearchModal from "./SearchModal";
import MobileMenu, { HamburgerButton } from "./MobileMenu";
import ScrollToTop, { ScrollToTopConfig } from "./ScrollToTop";
import { useSidebarOptional } from "../context/SidebarContext";
import siteConfig from "../config/siteConfig";
// Scroll-to-top configuration - enabled by default
@@ -28,6 +29,11 @@ export default function Layout({ children }: LayoutProps) {
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const location = useLocation();
// Get sidebar headings from context (if available)
const sidebarContext = useSidebarOptional();
const sidebarHeadings = sidebarContext?.headings || [];
const sidebarActiveId = sidebarContext?.activeId;
// Open search modal
const openSearch = useCallback(() => {
setIsSearchOpen(true);
@@ -116,12 +122,23 @@ export default function Layout({ children }: LayoutProps) {
<div className="layout">
{/* Top navigation bar with page links, search, and theme toggle */}
<div className="top-nav">
{/* Hamburger button for mobile menu (visible on mobile/tablet only) */}
<div className="mobile-menu-trigger">
<HamburgerButton
onClick={openMobileMenu}
isOpen={isMobileMenuOpen}
/>
{/* Mobile left controls: hamburger, search, theme (visible on mobile/tablet only) */}
<div className="mobile-nav-controls">
{/* Hamburger button for mobile menu */}
<HamburgerButton onClick={openMobileMenu} isOpen={isMobileMenuOpen} />
{/* Search button with icon */}
<button
onClick={openSearch}
className="search-button"
aria-label="Search (⌘K)"
title="Search (⌘K)"
>
<MagnifyingGlass size={18} weight="bold" />
</button>
{/* Theme toggle */}
<div className="theme-toggle-container">
<ThemeToggle />
</div>
</div>
{/* Page navigation links (visible on desktop only) */}
@@ -138,23 +155,31 @@ export default function Layout({ children }: LayoutProps) {
))}
</nav>
{/* Search button with icon */}
<button
onClick={openSearch}
className="search-button"
aria-label="Search (⌘K)"
title="Search (⌘K)"
>
<MagnifyingGlass size={18} weight="bold" />
</button>
{/* Theme toggle */}
<div className="theme-toggle-container">
<ThemeToggle />
{/* Desktop search and theme (visible on desktop only) */}
<div className="desktop-controls desktop-only">
{/* Search button with icon */}
<button
onClick={openSearch}
className="search-button"
aria-label="Search (⌘K)"
title="Search (⌘K)"
>
<MagnifyingGlass size={18} weight="bold" />
</button>
{/* Theme toggle */}
<div className="theme-toggle-container">
<ThemeToggle />
</div>
</div>
</div>
{/* Mobile menu drawer */}
<MobileMenu isOpen={isMobileMenuOpen} onClose={closeMobileMenu}>
<MobileMenu
isOpen={isMobileMenuOpen}
onClose={closeMobileMenu}
sidebarHeadings={sidebarHeadings}
sidebarActiveId={sidebarActiveId}
>
{/* Page navigation links in mobile menu (same order as desktop) */}
<nav className="mobile-nav-links">
{navItems.map((item) => (
@@ -171,7 +196,11 @@ export default function Layout({ children }: LayoutProps) {
</MobileMenu>
{/* Use wider layout for stats page, normal layout for other pages */}
<main className={location.pathname === "/stats" ? "main-content-wide" : "main-content"}>
<main
className={
location.pathname === "/stats" ? "main-content-wide" : "main-content"
}
>
{children}
</main>

View File

@@ -1,10 +1,14 @@
import { ReactNode, useEffect, useRef } from "react";
import { ReactNode, useEffect, useRef, useCallback } from "react";
import { Link } from "react-router-dom";
import { ChevronRight } from "lucide-react";
import { Heading } from "../utils/extractHeadings";
interface MobileMenuProps {
isOpen: boolean;
onClose: () => void;
children: ReactNode;
sidebarHeadings?: Heading[];
sidebarActiveId?: string;
}
/**
@@ -16,8 +20,11 @@ export default function MobileMenu({
isOpen,
onClose,
children,
sidebarHeadings = [],
sidebarActiveId,
}: MobileMenuProps) {
const menuRef = useRef<HTMLDivElement>(null);
const hasSidebar = sidebarHeadings.length > 0;
// Handle escape key to close menu
useEffect(() => {
@@ -56,6 +63,30 @@ export default function MobileMenu({
}
};
// Navigate to heading and close menu
const navigateToHeading = useCallback(
(id: string) => {
const element = document.getElementById(id);
if (element) {
// Close menu first
onClose();
// Scroll after menu closes
setTimeout(() => {
const headerOffset = 80;
const elementTop =
element.getBoundingClientRect().top + window.scrollY;
const targetPosition = elementTop - headerOffset;
window.scrollTo({
top: Math.max(0, targetPosition),
behavior: "smooth",
});
window.history.pushState(null, "", `#${id}`);
}, 100);
}
},
[onClose],
);
return (
<>
{/* Backdrop overlay */}
@@ -102,7 +133,30 @@ export default function MobileMenu({
</div>
{/* Menu content */}
<div className="mobile-menu-content">{children}</div>
<div className="mobile-menu-content">
{children}
{/* Table of contents from sidebar (if page has sidebar) */}
{hasSidebar && (
<div className="mobile-menu-toc">
<div className="mobile-menu-toc-title">On this page</div>
<nav className="mobile-menu-toc-links">
{sidebarHeadings.map((heading) => (
<button
key={heading.id}
onClick={() => navigateToHeading(heading.id)}
className={`mobile-menu-toc-link mobile-menu-toc-level-${heading.level} ${
sidebarActiveId === heading.id ? "active" : ""
}`}
>
<ChevronRight size={12} className="mobile-menu-toc-icon" />
{heading.text}
</button>
))}
</nav>
</div>
)}
</div>
</div>
</>
);

View File

@@ -1,27 +1,318 @@
import { useEffect, useState } from "react";
import { useEffect, useState, useMemo, useCallback, useRef } from "react";
import { Heading } from "../utils/extractHeadings";
import { ChevronRight } from "lucide-react";
interface PageSidebarProps {
headings: Heading[];
activeId?: string;
}
interface HeadingNode extends Heading {
children: HeadingNode[];
}
// Build a tree structure from flat headings array
function buildHeadingTree(headings: Heading[]): HeadingNode[] {
const tree: HeadingNode[] = [];
const stack: HeadingNode[] = [];
headings.forEach((heading) => {
const node: HeadingNode = { ...heading, children: [] };
// Pop stack until we find the parent (heading with lower level)
while (stack.length > 0 && stack[stack.length - 1].level >= heading.level) {
stack.pop();
}
if (stack.length === 0) {
// Root level heading
tree.push(node);
} else {
// Child of the last heading in stack
stack[stack.length - 1].children.push(node);
}
stack.push(node);
});
return tree;
}
// Load expanded state from localStorage
function loadExpandedState(headings: Heading[]): Set<string> {
const stored = localStorage.getItem("page-sidebar-expanded-state");
if (stored) {
try {
const storedIds = new Set(JSON.parse(stored));
// Only return stored IDs that still exist in headings
return new Set(
headings.filter((h) => storedIds.has(h.id)).map((h) => h.id),
);
} catch {
// If parse fails, return empty (collapsed)
}
}
// Default: all headings collapsed
return new Set();
}
// Save expanded state to localStorage
function saveExpandedState(expanded: Set<string>): void {
localStorage.setItem(
"page-sidebar-expanded-state",
JSON.stringify(Array.from(expanded)),
);
}
// Get absolute top position of an element
function getElementTop(element: HTMLElement): number {
const rect = element.getBoundingClientRect();
return rect.top + window.scrollY;
}
// Render a heading node recursively
function HeadingItem({
node,
activeId,
expanded,
onToggle,
onNavigate,
depth = 0,
}: {
node: HeadingNode;
activeId?: string;
expanded: Set<string>;
onToggle: (id: string) => void;
onNavigate: (id: string) => void;
depth?: number;
}) {
const hasChildren = node.children.length > 0;
const isExpanded = expanded.has(node.id);
const isActive = activeId === node.id;
return (
<li className="page-sidebar-item">
<div className="page-sidebar-item-wrapper">
{hasChildren && (
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onToggle(node.id);
}}
onMouseDown={(e) => {
// Prevent link click when clicking button
e.preventDefault();
}}
className={`page-sidebar-expand ${isExpanded ? "expanded" : ""}`}
aria-label={isExpanded ? "Collapse" : "Expand"}
aria-expanded={isExpanded}
>
<ChevronRight size={14} />
</button>
)}
{!hasChildren && <span className="page-sidebar-spacer" />}
<a
href={`#${node.id}`}
onClick={(e) => {
e.preventDefault();
onNavigate(node.id);
}}
className={`page-sidebar-link page-sidebar-item-level-${node.level} ${
isActive ? "active" : ""
}`}
>
{node.text}
</a>
</div>
{hasChildren && isExpanded && (
<ul className="page-sidebar-sublist">
{node.children.map((child) => (
<HeadingItem
key={child.id}
node={child}
activeId={activeId}
expanded={expanded}
onToggle={onToggle}
onNavigate={onNavigate}
depth={depth + 1}
/>
))}
</ul>
)}
</li>
);
}
export default function PageSidebar({ headings, activeId }: PageSidebarProps) {
const [activeHeading, setActiveHeading] = useState<string | undefined>(activeId);
const [activeHeading, setActiveHeading] = useState<string | undefined>(
activeId,
);
const [expanded, setExpanded] = useState<Set<string>>(() =>
loadExpandedState(headings),
);
// Track if we're currently navigating to prevent scroll handler interference
const isNavigatingRef = useRef(false);
// Build tree structure from headings
const headingTree = useMemo(() => buildHeadingTree(headings), [headings]);
// Get all heading IDs for scroll tracking
const allHeadingIds = useMemo(() => headings.map((h) => h.id), [headings]);
// Create a map for quick heading ID validation
const headingIdSet = useMemo(() => new Set(allHeadingIds), [allHeadingIds]);
// Find path to a heading ID in the tree (for expanding ancestors)
const findPathToId = useCallback(
(
nodes: HeadingNode[],
targetId: string,
path: HeadingNode[] = [],
): HeadingNode[] | null => {
for (const node of nodes) {
const currentPath = [...path, node];
if (node.id === targetId) {
return currentPath;
}
const found = findPathToId(node.children, targetId, currentPath);
if (found) return found;
}
return null;
},
[],
);
// Expand ancestors to make a heading visible in sidebar
const expandAncestors = useCallback(
(targetId: string) => {
const path = findPathToId(headingTree, targetId);
if (path && path.length > 1) {
const newExpanded = new Set(expanded);
let changed = false;
// Expand all ancestors (not the target itself)
path.slice(0, -1).forEach((node) => {
if (!newExpanded.has(node.id)) {
newExpanded.add(node.id);
changed = true;
}
});
if (changed) {
setExpanded(newExpanded);
saveExpandedState(newExpanded);
}
}
},
[expanded, headingTree, findPathToId],
);
// Toggle expand/collapse
const toggleExpand = useCallback((id: string) => {
setExpanded((prev) => {
const newExpanded = new Set(prev);
if (newExpanded.has(id)) {
newExpanded.delete(id);
} else {
newExpanded.add(id);
}
saveExpandedState(newExpanded);
return newExpanded;
});
}, []);
// Navigate to heading - scroll to element and update state
const navigateToHeading = useCallback(
(id: string) => {
// Expand ancestors first so sidebar shows the target
expandAncestors(id);
// Use requestAnimationFrame to ensure DOM updates are complete
requestAnimationFrame(() => {
const element = document.getElementById(id);
if (!element) {
return;
}
// Set flag to prevent scroll handler from changing active heading
isNavigatingRef.current = true;
// Calculate scroll position with offset for fixed header (80px)
const headerOffset = 80;
const elementTop = getElementTop(element);
const targetPosition = elementTop - headerOffset;
// Scroll to the target position
window.scrollTo({
top: Math.max(0, targetPosition),
behavior: "smooth",
});
// Update URL hash
window.history.pushState(null, "", `#${id}`);
// Update active heading
setActiveHeading(id);
// Reset navigation flag after scroll completes
setTimeout(() => {
isNavigatingRef.current = false;
}, 1000);
});
},
[expandAncestors],
);
// Handle initial URL hash on page load
useEffect(() => {
const hash = window.location.hash.slice(1);
if (hash && headingIdSet.has(hash)) {
// Delay to ensure DOM is ready and headings are rendered
const timeoutId = setTimeout(() => {
navigateToHeading(hash);
}, 200);
return () => clearTimeout(timeoutId);
}
}, [headingIdSet, navigateToHeading]);
// Handle hash changes (back/forward navigation)
useEffect(() => {
const handleHashChange = () => {
const hash = window.location.hash.slice(1);
if (hash && headingIdSet.has(hash)) {
navigateToHeading(hash);
}
};
window.addEventListener("hashchange", handleHashChange);
return () => window.removeEventListener("hashchange", handleHashChange);
}, [headingIdSet, navigateToHeading]);
// Update active heading on scroll
useEffect(() => {
if (headings.length === 0) return;
if (allHeadingIds.length === 0) return;
const handleScroll = () => {
const scrollPosition = window.scrollY + 100; // Offset for header
// Don't update if we're in the middle of navigating
if (isNavigatingRef.current) return;
const scrollPosition = window.scrollY + 120; // Offset for header
// Find the heading that's currently in view
for (let i = headings.length - 1; i >= 0; i--) {
const element = document.getElementById(headings[i].id);
if (element && element.offsetTop <= scrollPosition) {
setActiveHeading(headings[i].id);
break;
for (let i = allHeadingIds.length - 1; i >= 0; i--) {
const element = document.getElementById(allHeadingIds[i]);
if (element) {
const elementTop = getElementTop(element);
if (elementTop <= scrollPosition) {
const newActiveId = allHeadingIds[i];
setActiveHeading((prev) => {
// Only update if different - don't expand ancestors on scroll
// User can manually collapse/expand sections
return prev !== newActiveId ? newActiveId : prev;
});
break;
}
}
}
};
@@ -30,39 +321,31 @@ export default function PageSidebar({ headings, activeId }: PageSidebarProps) {
handleScroll(); // Initial check
return () => window.removeEventListener("scroll", handleScroll);
}, [headings]);
}, [allHeadingIds]);
// Auto-expand to show active heading from props
useEffect(() => {
if (activeId) {
expandAncestors(activeId);
}
}, [activeId, expandAncestors]);
if (headings.length === 0) return null;
return (
<nav className="page-sidebar">
<ul className="page-sidebar-list">
{headings.map((heading) => (
<li
key={heading.id}
className={`page-sidebar-item page-sidebar-item-level-${heading.level} ${
activeHeading === heading.id ? "active" : ""
}`}
>
<a
href={`#${heading.id}`}
onClick={(e) => {
e.preventDefault();
const element = document.getElementById(heading.id);
if (element) {
element.scrollIntoView({ behavior: "smooth", block: "start" });
// Update URL without scrolling
window.history.pushState(null, "", `#${heading.id}`);
}
}}
className="page-sidebar-link"
>
{heading.text}
</a>
</li>
{headingTree.map((node) => (
<HeadingItem
key={node.id}
node={node}
activeId={activeHeading}
expanded={expanded}
onToggle={toggleExpand}
onNavigate={navigateToHeading}
/>
))}
</ul>
</nav>
);
}

View File

@@ -0,0 +1,38 @@
import { createContext, useContext, useState, ReactNode } from "react";
import { Heading } from "../utils/extractHeadings";
interface SidebarContextType {
headings: Heading[];
setHeadings: (headings: Heading[]) => void;
activeId: string | undefined;
setActiveId: (id: string | undefined) => void;
}
const SidebarContext = createContext<SidebarContextType | undefined>(undefined);
export function SidebarProvider({ children }: { children: ReactNode }) {
const [headings, setHeadings] = useState<Heading[]>([]);
const [activeId, setActiveId] = useState<string | undefined>(undefined);
return (
<SidebarContext.Provider
value={{ headings, setHeadings, activeId, setActiveId }}
>
{children}
</SidebarContext.Provider>
);
}
export function useSidebar() {
const context = useContext(SidebarContext);
if (context === undefined) {
throw new Error("useSidebar must be used within a SidebarProvider");
}
return context;
}
// Optional hook that returns undefined if not within provider (for Layout)
export function useSidebarOptional() {
return useContext(SidebarContext);
}

View File

@@ -5,6 +5,7 @@ import BlogPost from "../components/BlogPost";
import CopyPageDropdown from "../components/CopyPageDropdown";
import PageSidebar from "../components/PageSidebar";
import { extractHeadings } from "../utils/extractHeadings";
import { useSidebar } from "../context/SidebarContext";
import { format, parseISO } from "date-fns";
import { ArrowLeft, Link as LinkIcon, Twitter, Rss } from "lucide-react";
import { useState, useEffect } from "react";
@@ -18,6 +19,7 @@ export default function Post() {
const { slug } = useParams<{ slug: string }>();
const navigate = useNavigate();
const location = useLocation();
const { setHeadings, setActiveId } = useSidebar();
// Check for page first, then post
const page = useQuery(api.pages.getPageBySlug, slug ? { slug } : "skip");
const post = useQuery(api.posts.getPostBySlug, slug ? { slug } : "skip");
@@ -40,6 +42,33 @@ export default function Post() {
return () => clearTimeout(timer);
}, [location.hash, page, post]);
// Update sidebar context with headings for mobile menu
useEffect(() => {
// Extract headings for pages with sidebar layout
if (page && page.layout === "sidebar") {
const pageHeadings = extractHeadings(page.content);
setHeadings(pageHeadings);
setActiveId(location.hash.slice(1) || undefined);
}
// Extract headings for posts with sidebar layout
else if (post && post.layout === "sidebar") {
const postHeadings = extractHeadings(post.content);
setHeadings(postHeadings);
setActiveId(location.hash.slice(1) || undefined);
}
// Clear headings when no sidebar
else if (page !== undefined || post !== undefined) {
setHeadings([]);
setActiveId(undefined);
}
// Cleanup: clear headings when leaving page
return () => {
setHeadings([]);
setActiveId(undefined);
};
}, [page, post, location.hash, setHeadings, setActiveId]);
// Update page title for static pages
useEffect(() => {
if (!page) return;

View File

@@ -61,6 +61,7 @@
--font-size-blog-h3: var(--font-size-xl);
--font-size-blog-h4: var(--font-size-base);
--font-size-blog-h5: var(--font-size-md);
--font-size-blog-h6: var(--font-size-sm);
/* Table font sizes */
--font-size-table: var(--font-size-md);
@@ -114,6 +115,8 @@
/* Mobile menu font sizes */
--font-size-mobile-nav-link: var(--font-size-base);
--font-size-mobile-home-link: var(--font-size-md);
--font-size-mobile-toc-title: var(--font-size-2xs);
--font-size-mobile-toc-link: var(--font-size-sm);
/* Copy dropdown font sizes */
--font-size-copy-trigger: var(--font-size-sm);
@@ -327,6 +330,20 @@ body {
border-radius: 8px;
}
/* Mobile nav controls (search, theme, hamburger) - shown on left on mobile */
.mobile-nav-controls {
display: none;
align-items: center;
gap: 4px;
}
/* Desktop controls (search, theme) - shown on right on desktop */
.desktop-controls {
display: flex;
align-items: center;
gap: 8px;
}
/* Page navigation links (About, Projects, Contact, etc.) */
.page-nav {
display: flex;
@@ -802,12 +819,54 @@ body {
padding: 0;
}
.page-sidebar-item-wrapper {
display: flex;
align-items: center;
gap: 4px;
}
.page-sidebar-expand {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
padding: 0;
background: transparent;
border: none;
color: var(--text-muted);
cursor: pointer;
border-radius: 4px;
transition: all 0.15s ease;
flex-shrink: 0;
}
.page-sidebar-expand:hover {
color: var(--text-primary);
background-color: var(--bg-hover);
}
.page-sidebar-expand svg {
transition: transform 0.15s ease;
}
.page-sidebar-expand.expanded svg {
transform: rotate(90deg);
}
.page-sidebar-spacer {
width: 20px;
flex-shrink: 0;
}
.page-sidebar-link {
display: block;
padding: 6px 12px;
flex: 1;
padding: 6px 8px;
color: var(--text-secondary);
text-decoration: none;
font-size: var(--font-size-sm);
font-family: inherit;
font-size: var(--font-size-md);
line-height: 1.5;
border-radius: 4px;
transition:
@@ -820,24 +879,35 @@ body {
background-color: var(--bg-hover);
}
.page-sidebar-item.active .page-sidebar-link {
.page-sidebar-link.active {
color: var(--text-primary);
background-color: var(--bg-hover);
font-weight: 500;
}
/* Indentation for nested headings */
.page-sidebar-item-level-1 .page-sidebar-link {
padding-left: 12px;
.page-sidebar-link.page-sidebar-item-level-1 {
font-weight: 500;
}
.page-sidebar-item-level-2 .page-sidebar-link {
padding-left: 24px;
.page-sidebar-sublist {
list-style: none;
padding: 0;
margin: 0;
margin-left: 20px;
}
.page-sidebar-item-level-3 .page-sidebar-link {
padding-left: 36px;
.page-sidebar-sublist .page-sidebar-link.page-sidebar-item-level-2 {
font-size: var(--font-size-sm);
}
.page-sidebar-sublist .page-sidebar-link.page-sidebar-item-level-3 {
font-size: var(--font-size-xs);
}
.page-sidebar-sublist .page-sidebar-link.page-sidebar-item-level-4,
.page-sidebar-sublist .page-sidebar-link.page-sidebar-item-level-5,
.page-sidebar-sublist .page-sidebar-link.page-sidebar-item-level-6 {
font-size: var(--font-size-xs);
}
@@ -854,6 +924,11 @@ body {
gap: 24px;
}
/* Hide sidebar on mobile - it's now in the hamburger menu */
.post-sidebar-wrapper {
display: none;
}
.post-sidebar-left {
position: static;
width: 100%;
@@ -887,11 +962,27 @@ body {
white-space: nowrap;
}
.page-sidebar-item-level-1 .page-sidebar-link,
.page-sidebar-item-level-2 .page-sidebar-link,
.page-sidebar-item-level-3 .page-sidebar-link {
padding-left: 8px;
padding-right: 8px;
.page-sidebar-expand {
width: 18px;
height: 18px;
}
.page-sidebar-expand svg {
width: 12px;
height: 12px;
}
.page-sidebar-spacer {
width: 18px;
}
.page-sidebar-link {
padding: 4px 6px;
font-size: var(--font-size-xs);
}
.page-sidebar-sublist {
margin-left: 16px;
}
}
@@ -992,6 +1083,13 @@ body {
line-height: 1.4;
}
.blog-h6 {
font-size: var(--font-size-blog-h6);
font-weight: 300;
margin: 16px 0 8px;
line-height: 1.4;
}
.blog-link {
color: var(--link-color);
text-decoration: underline;
@@ -1499,7 +1597,7 @@ body {
/* Responsive styles */
@media (max-width: 768px) {
.main-content {
padding: 40px 16px 16px 24px;
padding: 48px 16px 16px 24px;
}
.top-nav {
@@ -1572,6 +1670,10 @@ body {
font-size: var(--font-size-blog-h5);
}
.blog-h6 {
font-size: var(--font-size-blog-h6);
}
/* Table mobile styles */
.blog-table {
font-size: var(--font-size-table);
@@ -3441,8 +3543,8 @@ body {
Left-side drawer for mobile/tablet navigation
=========================================== */
/* Hide hamburger on desktop, show on mobile/tablet */
.mobile-menu-trigger {
/* Hide mobile controls on desktop, show on mobile/tablet */
.mobile-nav-controls {
display: none;
}
@@ -3452,9 +3554,18 @@ body {
}
@media (max-width: 768px) {
.mobile-menu-trigger {
/* Move top-nav to left side on mobile */
.top-nav {
right: auto;
left: 13px;
gap: 4px;
padding: 6px 10px;
}
.mobile-nav-controls {
display: flex !important;
align-items: center;
gap: 2px;
}
.desktop-only {
@@ -3553,7 +3664,7 @@ body {
/* Menu content area */
.mobile-menu-content {
flex: 1;
padding: 60px 24px 24px;
padding: 1px 16px 16px;
overflow-y: auto;
}
@@ -3561,17 +3672,18 @@ body {
.mobile-nav-links {
display: flex;
flex-direction: column;
gap: 4px;
gap: 2px;
}
.mobile-nav-link {
display: block;
padding: 12px 16px;
padding: 8px 12px;
color: var(--text-primary);
text-decoration: none;
font-family: inherit;
font-size: var(--font-size-mobile-nav-link);
font-weight: 500;
border-radius: 8px;
border-radius: 6px;
transition:
background-color 0.15s ease,
color 0.15s ease;
@@ -3581,9 +3693,93 @@ body {
background-color: var(--bg-hover);
}
/* Mobile menu table of contents */
.mobile-menu-toc {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid var(--border-color);
}
.mobile-menu-toc-title {
font-family: inherit;
font-size: var(--font-size-mobile-toc-title);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
padding: 4px 12px 8px;
}
.mobile-menu-toc-links {
display: flex;
flex-direction: column;
gap: 1px;
}
.mobile-menu-toc-link {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
padding: 6px 12px;
color: var(--text-secondary);
background: transparent;
border: none;
text-decoration: none;
font-family: inherit;
font-size: var(--font-size-mobile-toc-link);
text-align: left;
border-radius: 4px;
cursor: pointer;
transition:
background-color 0.15s ease,
color 0.15s ease;
}
.mobile-menu-toc-link:hover {
background-color: var(--bg-hover);
color: var(--text-primary);
}
.mobile-menu-toc-link.active {
color: var(--text-primary);
font-weight: 500;
}
.mobile-menu-toc-icon {
flex-shrink: 0;
opacity: 0.5;
}
/* TOC level indentation and font sizes */
.mobile-menu-toc-level-1 {
padding-left: 12px;
font-size: var(--font-size-mobile-toc-link);
}
.mobile-menu-toc-level-2 {
padding-left: 20px;
font-size: var(--font-size-mobile-toc-link);
}
.mobile-menu-toc-level-3 {
padding-left: 28px;
font-size: var(--font-size-xs);
}
.mobile-menu-toc-level-4,
.mobile-menu-toc-level-5,
.mobile-menu-toc-level-6 {
padding-left: 36px;
font-size: var(--font-size-xs);
}
.mobile-menu-toc-level-5 {
padding-left: 44px;
}
.mobile-menu-toc-level-6 {
padding-left: 52px;
}
/* Menu header with home link */
.mobile-menu-header {
padding: 16px 24px;
padding: 12px 16px;
border-bottom: 1px solid var(--border-color);
}
@@ -3597,11 +3793,12 @@ body {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
padding: 8px 12px;
color: var(--text-secondary);
text-decoration: none;
font-family: inherit;
font-size: var(--font-size-mobile-home-link);
border-radius: 8px;
border-radius: 6px;
transition:
background-color 0.15s ease,
color 0.15s ease;
@@ -3652,20 +3849,29 @@ body {
}
.mobile-menu-content {
padding: 56px 20px 20px;
padding: 1px 12px 12px;
}
.mobile-menu-footer {
padding: 12px 20px;
padding: 10px 12px;
}
.mobile-nav-link {
padding: 6px 10px;
font-size: 14px;
}
.mobile-menu-toc-link {
padding: 5px 10px;
font-size: var(--font-size-xs);
}
}
/* Desktop - hide mobile menu components */
@media (min-width: 769px) {
.mobile-menu-trigger,
.mobile-nav-controls,
.mobile-menu-backdrop,
.mobile-menu-drawer,
.hamburger-button {
.mobile-menu-drawer {
display: none !important;
}

View File

@@ -1,5 +1,5 @@
export interface Heading {
level: number; // 1, 2, or 3
level: number; // 1, 2, 3, 4, 5, or 6
text: string;
id: string;
}
@@ -14,17 +14,57 @@ function generateSlug(text: string): string {
.trim();
}
// Extract headings from markdown content
// Remove code blocks from content to avoid extracting headings from examples
function removeCodeBlocks(content: string): string {
// Remove fenced code blocks (``` or ~~~)
let result = content.replace(/```[\s\S]*?```/g, "");
result = result.replace(/~~~[\s\S]*?~~~/g, "");
// Remove indented code blocks (lines starting with 4+ spaces after a blank line)
// This is a simplified approach - we remove lines with 4+ leading spaces that aren't list items
const lines = result.split("\n");
const cleanedLines: string[] = [];
let inCodeBlock = false;
let prevLineBlank = true;
for (const line of lines) {
const isIndented = /^( |\t)/.test(line) && !line.trim().startsWith("-");
const isBlank = line.trim() === "";
if (isBlank) {
inCodeBlock = false;
prevLineBlank = true;
cleanedLines.push(line);
} else if (isIndented && prevLineBlank) {
inCodeBlock = true;
// Skip indented code block line
} else if (inCodeBlock && isIndented) {
// Skip continued indented code block line
} else {
inCodeBlock = false;
prevLineBlank = false;
cleanedLines.push(line);
}
}
return cleanedLines.join("\n");
}
// Extract headings from markdown content (supports H1-H6)
// Ignores headings inside code blocks
export function extractHeadings(content: string): Heading[] {
const headingRegex = /^(#{1,3})\s+(.+)$/gm;
// First remove code blocks to avoid extracting headings from code examples
const cleanContent = removeCodeBlocks(content);
const headingRegex = /^(#{1,6})\s+(.+)$/gm;
const headings: Heading[] = [];
let match;
while ((match = headingRegex.exec(content)) !== null) {
while ((match = headingRegex.exec(cleanContent)) !== null) {
const level = match[1].length;
const text = match[2].trim();
const id = generateSlug(text);
headings.push({ level, text, id });
}