feat: Add docsSectionGroupOrder frontmatter field for controlling docs sidebar group order

This commit is contained in:
Wayne Sutton
2026-01-02 23:11:35 -08:00
parent 46a1cdf591
commit 8fe6b53600
45 changed files with 2450 additions and 338 deletions

View File

@@ -0,0 +1,235 @@
---
name: Starlight-style Docs Section
overview: Implement a Starlight-style documentation section with configurable URL slug, left navigation sidebar showing grouped docs pages/posts, and right TOC sidebar for the current page content.
todos:
- id: config
content: Add DocsSectionConfig interface and docsSection config to siteConfig.ts
status: pending
- id: schema
content: Add docsSection, docsSectionGroup, docsSectionOrder fields to convex/schema.ts
status: pending
- id: queries
content: Add getDocsPosts and getDocsPages queries to convex/posts.ts and convex/pages.ts
status: pending
- id: sync
content: Update scripts/sync-posts.ts to parse new frontmatter fields
status: pending
- id: docs-sidebar
content: Create DocsSidebar.tsx component for left navigation
status: pending
- id: docs-toc
content: Create DocsTOC.tsx component for right table of contents
status: pending
- id: docs-layout
content: Create DocsLayout.tsx three-column layout wrapper
status: pending
- id: docs-page
content: Create DocsPage.tsx landing page component
status: pending
- id: post-integration
content: "Update Post.tsx to use DocsLayout when docsSection: true"
status: pending
- id: routing
content: Add docs route to App.tsx
status: pending
- id: styles
content: Add docs layout CSS styles to global.css
status: pending
- id: test-content
content: Add docsSection frontmatter to existing content for testing
status: pending
---
# Starlight-style Docs Section
## Overview
Create a documentation layout similar to [Astro Starlight](https://starlight.astro.build/manual-setup/) with:
- **Left sidebar**: Navigation menu showing grouped docs pages/posts (collapsible sections)
- **Right sidebar**: Table of contents for the current page/post
- **Main content**: The page/post content
- **Configurable**: Base URL slug set in siteConfig
## Architecture
```mermaid
flowchart TB
subgraph Config[Configuration Layer]
SC[siteConfig.docsSection]
FM[Frontmatter Fields]
end
subgraph Data[Data Layer]
Schema[convex/schema.ts]
Posts[convex/posts.ts]
Pages[convex/pages.ts]
end
subgraph Components[Component Layer]
DocsLayout[DocsLayout.tsx]
DocsSidebar[DocsSidebar.tsx]
DocsTOC[DocsTOC.tsx]
end
subgraph Routing[Routing Layer]
AppTsx[App.tsx]
DocsRoute[/docs route]
DocsPageRoute[/docs-page/:slug]
end
SC --> DocsLayout
FM --> Schema
Schema --> Posts
Schema --> Pages
Posts --> DocsSidebar
Pages --> DocsSidebar
DocsLayout --> DocsSidebar
DocsLayout --> DocsTOC
AppTsx --> DocsRoute
AppTsx --> DocsPageRoute
```
## Implementation Details
### 1. Site Configuration
Add to [`src/config/siteConfig.ts`](src/config/siteConfig.ts):
```typescript
docsSection: {
enabled: true,
slug: "docs", // Base URL: /docs
title: "Documentation", // Page title
showInNav: true, // Show in navigation
order: 1, // Nav order
defaultExpanded: true, // Expand all groups by default
}
```
### 2. New Frontmatter Fields
For pages and posts that should appear in docs navigation:
```yaml
---
title: "Setup Guide"
slug: "setup-guide"
docsSection: true # Include in docs navigation
docsSectionGroup: "Getting Started" # Group name in sidebar
docsSectionOrder: 1 # Order within group (lower = first)
---
```
### 3. Database Schema Updates
Add to [`convex/schema.ts`](convex/schema.ts) for both `posts` and `pages` tables:
```typescript
docsSection: v.optional(v.boolean()), // Include in docs navigation
docsSectionGroup: v.optional(v.string()), // Sidebar group name
docsSectionOrder: v.optional(v.number()), // Order within group
```
### 4. New Components
**DocsSidebar.tsx** (left navigation):
- Fetches all pages/posts with `docsSection: true`
- Groups by `docsSectionGroup`
- Sorts by `docsSectionOrder`
- Collapsible group sections with ChevronRight icons
- Highlights current page
- Persists expanded state to localStorage
**DocsTOC.tsx** (right TOC):
- Reuses heading extraction from `extractHeadings.ts`
- Similar to current `PageSidebar.tsx` but positioned on right
- Active heading highlighting on scroll
- Smooth scroll navigation
**DocsLayout.tsx** (three-column layout):
- Left: DocsSidebar (navigation)
- Center: Main content
- Right: DocsTOC (table of contents)
- Responsive: stacks on mobile
### 5. Routing Changes
Update [`src/App.tsx`](src/App.tsx):
```typescript
// Docs landing page (shows first doc or overview)
{
siteConfig.docsSection?.enabled && (
<Route path={`/${siteConfig.docsSection.slug}`} element={<DocsPage />} />
);
}
// Existing catch-all handles individual doc pages
<Route path="/:slug" element={<Post />} />;
```
### 6. Post/Page Rendering
Update [`src/pages/Post.tsx`](src/pages/Post.tsx):
- Detect if current page/post has `docsSection: true`
- If yes, render with `DocsLayout` instead of current layout
- Pass headings to DocsTOC for right sidebar
### 7. Sync Script Updates
Update [`scripts/sync-posts.ts`](scripts/sync-posts.ts):
- Parse new frontmatter fields: `docsSection`, `docsSectionGroup`, `docsSectionOrder`
- Include in sync payload for both posts and pages
### 8. CSS Styling
Add to [`src/styles/global.css`](src/styles/global.css):
- Three-column docs grid layout
- DocsSidebar styles (groups, links, active states)
- DocsTOC styles (right-aligned, smaller font)
- Mobile responsive breakpoints
- Theme-aware colors using existing CSS variables
## File Changes Summary
| File | Change |
| -------------------------------- | ---------------------------------------------------------------- |
| `src/config/siteConfig.ts` | Add `DocsSectionConfig` interface and `docsSection` config |
| `convex/schema.ts` | Add `docsSection`, `docsSectionGroup`, `docsSectionOrder` fields |
| `convex/posts.ts` | Add `getDocsPosts` query |
| `convex/pages.ts` | Add `getDocsPages` query |
| `scripts/sync-posts.ts` | Parse new frontmatter fields |
| `src/components/DocsSidebar.tsx` | New component for left navigation |
| `src/components/DocsTOC.tsx` | New component for right TOC |
| `src/components/DocsLayout.tsx` | New three-column layout wrapper |
| `src/pages/DocsPage.tsx` | New docs landing page |
| `src/pages/Post.tsx` | Conditional docs layout rendering |
| `src/App.tsx` | Add docs route |
| `src/styles/global.css` | Add docs layout styles |
## Migration Path
1. Existing pages/posts continue working unchanged
2. Add `docsSection: true` to content you want in docs navigation
3. Set base slug in siteConfig (default: "docs")
4. Run `npm run sync` to update database with new fields

View File

@@ -4,6 +4,7 @@
- [ ] docs pages - [ ] docs pages
- [ ] fix site confg link - [ ] fix site confg link
- [ ] npm package - [ ] npm package
## Current Status ## Current Status

View File

@@ -4,6 +4,22 @@ 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/). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [2.7.0] - 2026-01-02
### Added
- `docsSectionGroupOrder` frontmatter field for controlling docs sidebar group order
- Groups are sorted by the minimum `docsSectionGroupOrder` value among items in each group
- Lower numbers appear first, groups without this field sort alphabetically
- Works alongside `docsSection`, `docsSectionGroup`, and `docsSectionOrder` fields
### Technical
- Updated `convex/schema.ts` to include `docsSectionGroupOrder` field in posts and pages tables
- Updated `convex/posts.ts` and `convex/pages.ts` queries and mutations to handle `docsSectionGroupOrder`
- Updated `scripts/sync-posts.ts` to parse `docsSectionGroupOrder` from frontmatter
- Updated `src/components/DocsSidebar.tsx` to sort groups by `docsSectionGroupOrder`
## [2.6.0] - 2026-01-01 ## [2.6.0] - 2026-01-01
### Added ### Added

View File

@@ -13,6 +13,10 @@ authorName: "Markdown"
authorImage: "/images/authors/markdown.png" authorImage: "/images/authors/markdown.png"
image: "/images/forkconfig.png" image: "/images/forkconfig.png"
excerpt: "Set up your forked site with npm run configure or follow the manual FORK_CONFIG.md guide." excerpt: "Set up your forked site with npm run configure or follow the manual FORK_CONFIG.md guide."
docsSection: true
docsSectionGroup: "Setup"
docsSectionGroupOrder: 1
docsSectionOrder: 1
--- ---
# Configure your fork in one command # Configure your fork in one command
@@ -97,19 +101,19 @@ If you prefer to update files manually, follow the guide in `FORK_CONFIG.md`. It
The configuration script updates these files: The configuration script updates these files:
| File | What changes | | File | What changes |
| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------ | | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `src/config/siteConfig.ts` | Site name, bio, GitHub username, gitHubRepo config, features (logo gallery, GitHub contributions, visitor map, blog page, posts display, homepage, right sidebar, footer, social footer, AI chat, newsletter, contact form, newsletter admin, stats page, MCP server, dashboard, image lightbox) | | `src/config/siteConfig.ts` | Site name, bio, GitHub username, gitHubRepo config, features (logo gallery, GitHub contributions, visitor map, blog page, posts display, homepage, right sidebar, footer, social footer, AI chat, newsletter, contact form, newsletter admin, stats page, MCP server, dashboard, image lightbox) |
| `src/pages/Home.tsx` | Intro paragraph, footer links | | `src/pages/Home.tsx` | Intro paragraph, footer links |
| `src/pages/Post.tsx` | SITE_URL, SITE_NAME constants | | `src/pages/Post.tsx` | SITE_URL, SITE_NAME constants |
| `convex/http.ts` | SITE_URL, SITE_NAME constants | | `convex/http.ts` | SITE_URL, SITE_NAME constants |
| `convex/rss.ts` | SITE_URL, SITE_TITLE, SITE_DESCRIPTION | | `convex/rss.ts` | SITE_URL, SITE_TITLE, SITE_DESCRIPTION |
| `index.html` | Meta tags, JSON-LD, page title | | `index.html` | Meta tags, JSON-LD, page title |
| `public/llms.txt` | Site info, GitHub link | | `public/llms.txt` | Site info, GitHub link |
| `public/robots.txt` | Sitemap URL | | `public/robots.txt` | Sitemap URL |
| `public/openapi.yaml` | Server URL, site name | | `public/openapi.yaml` | Server URL, site name |
| `public/.well-known/ai-plugin.json` | Plugin metadata | | `public/.well-known/ai-plugin.json` | Plugin metadata |
| `src/context/ThemeContext.tsx` | Default theme | | `src/context/ThemeContext.tsx` | Default theme |
## Optional settings ## Optional settings

View File

@@ -14,6 +14,13 @@ blogFeatured: true
authorImage: "/images/authors/markdown.png" authorImage: "/images/authors/markdown.png"
image: "/images/matthew-smith-Rfflri94rs8-unsplash.jpg" image: "/images/matthew-smith-Rfflri94rs8-unsplash.jpg"
excerpt: "Quick guide to writing and publishing markdown posts with npm run sync." excerpt: "Quick guide to writing and publishing markdown posts with npm run sync."
aiChat: true
docsSection: true
docsSectionGroup: "Publishing"
docsSectionOrder: 3
docsSectionGroupOrder: 3
---
--- ---
# How to Publish a Blog Post # How to Publish a Blog Post

View File

@@ -11,6 +11,10 @@ featuredOrder: 4
layout: "sidebar" layout: "sidebar"
image: /images/workos.png image: /images/workos.png
excerpt: "Complete guide to setting up WorkOS AuthKit authentication for your dashboard. WorkOS is optional and can be configured in siteConfig.ts." excerpt: "Complete guide to setting up WorkOS AuthKit authentication for your dashboard. WorkOS is optional and can be configured in siteConfig.ts."
docsSection: true
docsSectionOrder: 2
docsSectionGroup: "Components"
docsLanding: true
--- ---
# How to setup WorkOS # How to setup WorkOS

View File

@@ -10,6 +10,10 @@ layout: "sidebar"
blogFeatured: true blogFeatured: true
image: /images/agentmail-blog.png image: /images/agentmail-blog.png
tags: ["agentmail", "newsletter", "email", "setup"] tags: ["agentmail", "newsletter", "email", "setup"]
docsSection: true
docsSectionOrder: 2
docsSectionGroup: "Components"
docsLanding: true
--- ---
AgentMail provides email infrastructure for your markdown blog, enabling newsletter subscriptions, contact forms, and automated email notifications. This guide covers setup, configuration, and usage. AgentMail provides email infrastructure for your markdown blog, enabling newsletter subscriptions, contact forms, and automated email notifications. This guide covers setup, configuration, and usage.

View File

@@ -8,6 +8,10 @@ featured: true
featuredOrder: 6 featuredOrder: 6
image: /images/firecrwall-blog.png image: /images/firecrwall-blog.png
tags: ["tutorial", "firecrawl", "import"] tags: ["tutorial", "firecrawl", "import"]
docsSection: true
docsSectionOrder: 2
docsSectionGroup: "Components"
docsLanding: true
--- ---
# How to use Firecrawl # How to use Firecrawl

View File

@@ -8,6 +8,10 @@ published: true
blogFeatured: true blogFeatured: true
layout: "sidebar" layout: "sidebar"
tags: ["mcp", "cursor", "ai", "tutorial", "netlify"] tags: ["mcp", "cursor", "ai", "tutorial", "netlify"]
docsSection: true
docsSectionOrder: 2
docsSectionGroup: "Components"
docsLanding: true
--- ---
This site includes an HTTP-based Model Context Protocol (MCP) server that allows AI tools like Cursor, Claude Desktop, and other MCP-compatible clients to access blog content programmatically. This site includes an HTTP-based Model Context Protocol (MCP) server that allows AI tools like Cursor, Claude Desktop, and other MCP-compatible clients to access blog content programmatically.

View File

@@ -11,6 +11,10 @@ layout: "sidebar"
featuredOrder: 2 featuredOrder: 2
image: /images/dashboard.png image: /images/dashboard.png
excerpt: "A complete guide to using the dashboard for managing your markdown blog without leaving your browser." excerpt: "A complete guide to using the dashboard for managing your markdown blog without leaving your browser."
docsSection: true
docsSectionOrder: 2
docsSectionGroup: "Components"
docsLanding: true
--- ---
# How to use the Markdown sync dashboard # How to use the Markdown sync dashboard

View File

@@ -12,6 +12,10 @@ featured: false
layout: "sidebar" layout: "sidebar"
featuredOrder: 5 featuredOrder: 5
image: "/images/markdown.png" image: "/images/markdown.png"
docsSection: true
docsSectionOrder: 3
docsSectionGroup: "Publishing"
docsLanding: true
--- ---
# Writing Markdown with Code Examples # Writing Markdown with Code Examples
@@ -346,7 +350,8 @@ Embed a YouTube video using an iframe:
height="315" height="315"
src="https://www.youtube.com/embed/dQw4w9WgXcQ" src="https://www.youtube.com/embed/dQw4w9WgXcQ"
title="YouTube video" title="YouTube video"
allowfullscreen> allowfullscreen
>
</iframe> </iframe>
``` ```
@@ -362,7 +367,8 @@ Embed a tweet using the Twitter embed URL:
<iframe <iframe
src="https://platform.twitter.com/embed/Tweet.html?id=20" src="https://platform.twitter.com/embed/Tweet.html?id=20"
width="550" width="550"
height="250"> height="250"
>
</iframe> </iframe>
``` ```
@@ -380,7 +386,8 @@ Use `youtube-nocookie.com` for privacy-enhanced embeds:
height="315" height="315"
src="https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ" src="https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ"
title="YouTube video" title="YouTube video"
allowfullscreen> allowfullscreen
>
</iframe> </iframe>
``` ```

View File

@@ -14,6 +14,10 @@ image: "/images/setupguide.png"
authorName: "Markdown" authorName: "Markdown"
authorImage: "/images/authors/markdown.png" authorImage: "/images/authors/markdown.png"
excerpt: "Complete guide to fork, set up, and deploy your own markdown framework in under 10 minutes." excerpt: "Complete guide to fork, set up, and deploy your own markdown framework in under 10 minutes."
docsSection: true
docsSectionOrder: 1
docsSectionGroup: "Setup"
docsLanding: true
--- ---
# Fork and Deploy Your Own Markdown Framework # Fork and Deploy Your Own Markdown Framework
@@ -63,6 +67,7 @@ This guide walks you through forking [this markdown framework](https://github.co
- [Visitor Map](#visitor-map) - [Visitor Map](#visitor-map)
- [Logo Gallery](#logo-gallery) - [Logo Gallery](#logo-gallery)
- [Blog page](#blog-page) - [Blog page](#blog-page)
- [Homepage Post Limit](#homepage-post-limit)
- [Hardcoded Navigation Items](#hardcoded-navigation-items) - [Hardcoded Navigation Items](#hardcoded-navigation-items)
- [Scroll-to-top button](#scroll-to-top-button) - [Scroll-to-top button](#scroll-to-top-button)
- [Change the Default Theme](#change-the-default-theme) - [Change the Default Theme](#change-the-default-theme)
@@ -71,10 +76,15 @@ This guide walks you through forking [this markdown framework](https://github.co
- [Add Static Pages (Optional)](#add-static-pages-optional) - [Add Static Pages (Optional)](#add-static-pages-optional)
- [Update SEO Meta Tags](#update-seo-meta-tags) - [Update SEO Meta Tags](#update-seo-meta-tags)
- [Update llms.txt and robots.txt](#update-llmstxt-and-robotstxt) - [Update llms.txt and robots.txt](#update-llmstxt-and-robotstxt)
- [Tag Pages and Related Posts](#tag-pages-and-related-posts)
- [Search](#search) - [Search](#search)
- [Using Search](#using-search) - [Using Search](#using-search)
- [How It Works](#how-it-works) - [How It Works](#how-it-works)
- [Real-time Stats](#real-time-stats) - [Real-time Stats](#real-time-stats)
- [Footer Configuration](#footer-configuration)
- [Social Footer Configuration](#social-footer-configuration)
- [Right Sidebar Configuration](#right-sidebar-configuration)
- [Contact Form Configuration](#contact-form-configuration)
- [Newsletter Admin](#newsletter-admin) - [Newsletter Admin](#newsletter-admin)
- [Mobile Navigation](#mobile-navigation) - [Mobile Navigation](#mobile-navigation)
- [Copy Page Dropdown](#copy-page-dropdown) - [Copy Page Dropdown](#copy-page-dropdown)
@@ -87,7 +97,9 @@ This guide walks you through forking [this markdown framework](https://github.co
- [Project Structure](#project-structure) - [Project Structure](#project-structure)
- [Write Page](#write-page) - [Write Page](#write-page)
- [AI Agent chat](#ai-agent-chat) - [AI Agent chat](#ai-agent-chat)
- [Dashboard](#dashboard)
- [Next Steps](#next-steps) - [Next Steps](#next-steps)
- [MCP Server](#mcp-server)
## Prerequisites ## Prerequisites
@@ -336,23 +348,27 @@ Your markdown content here...
### Frontmatter Fields ### Frontmatter Fields
| Field | Required | Description | | Field | Required | Description |
| --------------- | -------- | ----------------------------------------------------------------------------- | | --------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `title` | Yes | Post title | | `title` | Yes | Post title |
| `description` | Yes | Short description for SEO | | `description` | Yes | Short description for SEO |
| `date` | Yes | Publication date (YYYY-MM-DD) | | `date` | Yes | Publication date (YYYY-MM-DD) |
| `slug` | Yes | URL path (must be unique) | | `slug` | Yes | URL path (must be unique) |
| `published` | Yes | Set to `true` to publish | | `published` | Yes | Set to `true` to publish |
| `tags` | Yes | Array of topic tags | | `tags` | Yes | Array of topic tags |
| `readTime` | No | Estimated reading time | | `readTime` | No | Estimated reading time |
| `image` | No | Header/Open Graph image URL | | `image` | No | Header/Open Graph image URL |
| `excerpt` | No | Short excerpt for card view | | `excerpt` | No | Short excerpt for card view |
| `featured` | No | Set `true` to show in featured section | | `featured` | No | Set `true` to show in featured section |
| `featuredOrder` | No | Order in featured section (lower = first) | | `featuredOrder` | No | Order in featured section (lower = first) |
| `authorName` | No | Author display name shown next to date | | `authorName` | No | Author display name shown next to date |
| `authorImage` | No | Round author avatar image URL | | `authorImage` | No | Round author avatar image URL |
| `rightSidebar` | No | Enable right sidebar with CopyPageDropdown (opt-in, requires explicit `true`) | | `rightSidebar` | No | Enable right sidebar with CopyPageDropdown (opt-in, requires explicit `true`) |
| `unlisted` | No | Hide from listings but allow direct access via slug. Set `true` to hide from blog listings, featured sections, tag pages, search results, and related posts. Post remains accessible via direct link. | | `unlisted` | No | Hide from listings but allow direct access via slug. Set `true` to hide from blog listings, featured sections, tag pages, search results, and related posts. Post remains accessible via direct link. |
| `docsSection` | No | Include in docs sidebar. Set `true` to show in the docs section navigation. |
| `docsSectionGroup` | No | Group name for docs sidebar. Posts with the same group name appear together. |
| `docsSectionOrder` | No | Order within docs group. Lower numbers appear first within the group. |
| `docsSectionGroupOrder` | No | Order of the group in docs sidebar. Lower numbers make the group appear first. Groups without this field sort alphabetically. |
### How Frontmatter Works ### How Frontmatter Works
@@ -1591,11 +1607,11 @@ The Dashboard includes a dedicated AI Agent section with a tab-based UI for Chat
Agent requires API keys for the providers you want to use. Set these in Convex environment variables: Agent requires API keys for the providers you want to use. Set these in Convex environment variables:
| Variable | Provider | Features | | Variable | Provider | Features |
| --- | --- | --- | | ------------------- | --------- | ---------------------------------------- |
| `ANTHROPIC_API_KEY` | Anthropic | Claude Sonnet 4 chat | | `ANTHROPIC_API_KEY` | Anthropic | Claude Sonnet 4 chat |
| `OPENAI_API_KEY` | OpenAI | GPT-4o chat | | `OPENAI_API_KEY` | OpenAI | GPT-4o chat |
| `GOOGLE_AI_API_KEY` | Google | Gemini 2.0 Flash chat + image generation | | `GOOGLE_AI_API_KEY` | Google | Gemini 2.0 Flash chat + image generation |
**Optional system prompt variables:** **Optional system prompt variables:**
@@ -1680,6 +1696,7 @@ npm run sync-server
``` ```
This starts a local HTTP server on `localhost:3001` that: This starts a local HTTP server on `localhost:3001` that:
- Executes sync commands when requested from the dashboard - Executes sync commands when requested from the dashboard
- Streams output in real-time to the dashboard terminal view - Streams output in real-time to the dashboard terminal view
- Shows server status (online/offline) in the dashboard - Shows server status (online/offline) in the dashboard

View File

@@ -11,6 +11,10 @@ featured: false
layout: "sidebar" layout: "sidebar"
newsletter: true newsletter: true
excerpt: "Learn how teams use git for markdown version control, sync to Convex deployments, and automate production workflows." excerpt: "Learn how teams use git for markdown version control, sync to Convex deployments, and automate production workflows."
docsSection: true
docsSectionOrder: 1
docsSectionGroup: "Setup"
docsLanding: true
--- ---
# Team Workflows with Git Version Control # Team Workflows with Git Version Control

View File

@@ -14,6 +14,10 @@ showImageAtTop: true
authorName: "Markdown" authorName: "Markdown"
authorImage: "/images/authors/markdown.png" authorImage: "/images/authors/markdown.png"
image: "https://images.unsplash.com/photo-1499750310107-5fef28a66643?w=1200&h=630&fit=crop" image: "https://images.unsplash.com/photo-1499750310107-5fef28a66643?w=1200&h=630&fit=crop"
docsSection: true
docsSectionOrder: 3
docsSectionGroup: "Publishing"
docsLanding: true
--- ---
# Using Images in Blog Posts # Using Images in Blog Posts

View File

@@ -5,11 +5,42 @@ published: true
order: 5 order: 5
rightSidebar: false rightSidebar: false
layout: "sidebar" layout: "sidebar"
docsSection: true
docsSectionOrder: 4
--- ---
All notable changes to this project. All notable changes to this project.
![](https://img.shields.io/badge/License-MIT-yellow.svg) ![](https://img.shields.io/badge/License-MIT-yellow.svg)
## v2.7.0
Released January 2, 2026
**Docs sidebar group ordering**
- New `docsSectionGroupOrder` frontmatter field for controlling docs sidebar group order
- Groups are sorted by the minimum `docsSectionGroupOrder` value among items in each group
- Lower numbers appear first, groups without this field sort alphabetically
- Works alongside `docsSection`, `docsSectionGroup`, and `docsSectionOrder` fields
**Example usage:**
```yaml
---
docsSection: true
docsSectionGroup: "Getting Started"
docsSectionGroupOrder: 1
docsSectionOrder: 1
---
```
**Technical details:**
- Updated `convex/schema.ts` with `docsSectionGroupOrder` field in posts and pages tables
- Updated `convex/posts.ts` and `convex/pages.ts` queries and mutations
- Updated `scripts/sync-posts.ts` to parse `docsSectionGroupOrder` from frontmatter
- Updated `src/components/DocsSidebar.tsx` to sort groups by `docsSectionGroupOrder`
## v2.6.0 ## v2.6.0
Released January 1, 2026 Released January 1, 2026

View File

@@ -1,12 +1,17 @@
--- ---
title: "Docs" title: "Documentation"
slug: "docs" slug: "documentation"
published: true published: true
order: 0 order: 0
showInNav: false
layout: "sidebar" layout: "sidebar"
aiChat: true aiChat: true
rightSidebar: true rightSidebar: true
showFooter: true showFooter: true
docsSection: true
docsSectionOrder: 1
docsSectionGroup: "Setup"
docsLanding: true
--- ---
## Getting Started ## Getting Started
@@ -130,6 +135,10 @@ Content here...
| `newsletter` | No | Override newsletter signup display (`true` to show, `false` to hide) | | `newsletter` | No | Override newsletter signup display (`true` to show, `false` to hide) |
| `contactForm` | No | Enable contact form on this post | | `contactForm` | No | Enable contact form on this post |
| `unlisted` | No | Hide from listings but allow direct access via slug. Set `true` to hide from blog listings, featured sections, tag pages, search results, and related posts. Post remains accessible via direct link. | | `unlisted` | No | Hide from listings but allow direct access via slug. Set `true` to hide from blog listings, featured sections, tag pages, search results, and related posts. Post remains accessible via direct link. |
| `docsSection` | No | Include in docs sidebar. Set `true` to show in the docs section navigation. |
| `docsSectionGroup` | No | Group name for docs sidebar. Posts with the same group name appear together. |
| `docsSectionOrder` | No | Order within docs group. Lower numbers appear first within the group. |
| `docsSectionGroupOrder` | No | Order of the group in docs sidebar. Lower numbers make the group appear first. Groups without this field sort alphabetically. |
| `showImageAtTop` | No | Set `true` to display the `image` field at the top of the post above the header (default: `false`) | | `showImageAtTop` | No | Set `true` to display the `image` field at the top of the post above the header (default: `false`) |
### Static pages ### Static pages
@@ -173,6 +182,10 @@ Content here...
| `contactForm` | No | Enable contact form on this page | | `contactForm` | No | Enable contact form on this page |
| `showImageAtTop` | No | Set `true` to display the `image` field at the top of the page above the header (default: `false`) | | `showImageAtTop` | No | Set `true` to display the `image` field at the top of the page above the header (default: `false`) |
| `textAlign` | No | Text alignment: "left" (default), "center", or "right". Used by `home.md` for home intro alignment | | `textAlign` | No | Text alignment: "left" (default), "center", or "right". Used by `home.md` for home intro alignment |
| `docsSection` | No | Include in docs sidebar. Set `true` to show in the docs section navigation. |
| `docsSectionGroup` | No | Group name for docs sidebar. Pages with the same group name appear together. |
| `docsSectionOrder` | No | Order within docs group. Lower numbers appear first within the group. |
| `docsSectionGroupOrder` | No | Order of the group in docs sidebar. Lower numbers make the group appear first. Groups without this field sort alphabetically. |
**Hide pages from navigation:** Set `showInNav: false` to keep a page published and accessible via direct URL, but hidden from the navigation menu. Pages with `showInNav: false` remain searchable and available via API endpoints. Useful for pages you want to link directly but not show in the main nav. **Hide pages from navigation:** Set `showInNav: false` to keep a page published and accessible via direct URL, but hidden from the navigation menu. Pages with `showInNav: false` remain searchable and available via API endpoints. Useful for pages you want to link directly but not show in the main nav.
@@ -1117,10 +1130,10 @@ The Dashboard includes a dedicated AI Agent section with tab-based UI for Chat a
**Environment Variables (Convex):** **Environment Variables (Convex):**
| Variable | Description | | Variable | Description |
| --- | --- | | ------------------- | -------------------------------------------------- |
| `ANTHROPIC_API_KEY` | Required for Claude Sonnet 4 | | `ANTHROPIC_API_KEY` | Required for Claude Sonnet 4 |
| `OPENAI_API_KEY` | Required for GPT-4o | | `OPENAI_API_KEY` | Required for GPT-4o |
| `GOOGLE_AI_API_KEY` | Required for Gemini 2.0 Flash and image generation | | `GOOGLE_AI_API_KEY` | Required for Gemini 2.0 Flash and image generation |
**Note:** Only configure the API keys for models you want to use. If a key is not set, users see a helpful setup message when they try to use that model. **Note:** Only configure the API keys for models you want to use. If a key is not set, users see a helpful setup message when they try to use that model.

View File

@@ -184,6 +184,7 @@ export const getPageBySlug = query({
contactForm: v.optional(v.boolean()), contactForm: v.optional(v.boolean()),
newsletter: v.optional(v.boolean()), newsletter: v.optional(v.boolean()),
textAlign: v.optional(v.string()), textAlign: v.optional(v.string()),
docsSection: v.optional(v.boolean()),
}), }),
v.null(), v.null(),
), ),
@@ -221,6 +222,94 @@ export const getPageBySlug = query({
contactForm: page.contactForm, contactForm: page.contactForm,
newsletter: page.newsletter, newsletter: page.newsletter,
textAlign: page.textAlign, textAlign: page.textAlign,
docsSection: page.docsSection,
};
},
});
// Get all pages marked for docs section navigation
// Used by DocsSidebar to build the left navigation
export const getDocsPages = query({
args: {},
returns: v.array(
v.object({
_id: v.id("pages"),
slug: v.string(),
title: v.string(),
docsSectionGroup: v.optional(v.string()),
docsSectionOrder: v.optional(v.number()),
docsSectionGroupOrder: v.optional(v.number()),
}),
),
handler: async (ctx) => {
const pages = await ctx.db
.query("pages")
.withIndex("by_docsSection", (q) => q.eq("docsSection", true))
.collect();
// Filter to only published pages
const publishedDocs = pages.filter((p) => p.published);
// Sort by docsSectionOrder, then by title
const sortedDocs = publishedDocs.sort((a, b) => {
const orderA = a.docsSectionOrder ?? 999;
const orderB = b.docsSectionOrder ?? 999;
if (orderA !== orderB) return orderA - orderB;
return a.title.localeCompare(b.title);
});
return sortedDocs.map((page) => ({
_id: page._id,
slug: page.slug,
title: page.title,
docsSectionGroup: page.docsSectionGroup,
docsSectionOrder: page.docsSectionOrder,
docsSectionGroupOrder: page.docsSectionGroupOrder,
}));
},
});
// Get the docs landing page (page with docsLanding: true)
// Returns null if no landing page is set
export const getDocsLandingPage = query({
args: {},
returns: v.union(
v.object({
_id: v.id("pages"),
slug: v.string(),
title: v.string(),
content: v.string(),
image: v.optional(v.string()),
showImageAtTop: v.optional(v.boolean()),
authorName: v.optional(v.string()),
authorImage: v.optional(v.string()),
docsSectionGroup: v.optional(v.string()),
docsSectionOrder: v.optional(v.number()),
}),
v.null(),
),
handler: async (ctx) => {
// Get all docs pages and find one with docsLanding: true
const pages = await ctx.db
.query("pages")
.withIndex("by_docsSection", (q) => q.eq("docsSection", true))
.collect();
const landing = pages.find((p) => p.published && p.docsLanding);
if (!landing) return null;
return {
_id: landing._id,
slug: landing.slug,
title: landing.title,
content: landing.content,
image: landing.image,
showImageAtTop: landing.showImageAtTop,
authorName: landing.authorName,
authorImage: landing.authorImage,
docsSectionGroup: landing.docsSectionGroup,
docsSectionOrder: landing.docsSectionOrder,
}; };
}, },
}); });
@@ -252,6 +341,11 @@ export const syncPagesPublic = mutation({
contactForm: v.optional(v.boolean()), contactForm: v.optional(v.boolean()),
newsletter: v.optional(v.boolean()), newsletter: v.optional(v.boolean()),
textAlign: v.optional(v.string()), textAlign: v.optional(v.string()),
docsSection: v.optional(v.boolean()),
docsSectionGroup: v.optional(v.string()),
docsSectionOrder: v.optional(v.number()),
docsSectionGroupOrder: v.optional(v.number()),
docsLanding: v.optional(v.boolean()),
}), }),
), ),
}, },
@@ -300,6 +394,11 @@ export const syncPagesPublic = mutation({
contactForm: page.contactForm, contactForm: page.contactForm,
newsletter: page.newsletter, newsletter: page.newsletter,
textAlign: page.textAlign, textAlign: page.textAlign,
docsSection: page.docsSection,
docsSectionGroup: page.docsSectionGroup,
docsSectionOrder: page.docsSectionOrder,
docsSectionGroupOrder: page.docsSectionGroupOrder,
docsLanding: page.docsLanding,
lastSyncedAt: now, lastSyncedAt: now,
}); });
updated++; updated++;

View File

@@ -238,6 +238,7 @@ export const getPostBySlug = query({
aiChat: v.optional(v.boolean()), aiChat: v.optional(v.boolean()),
newsletter: v.optional(v.boolean()), newsletter: v.optional(v.boolean()),
contactForm: v.optional(v.boolean()), contactForm: v.optional(v.boolean()),
docsSection: v.optional(v.boolean()),
}), }),
v.null(), v.null(),
), ),
@@ -277,6 +278,7 @@ export const getPostBySlug = query({
aiChat: post.aiChat, aiChat: post.aiChat,
newsletter: post.newsletter, newsletter: post.newsletter,
contactForm: post.contactForm, contactForm: post.contactForm,
docsSection: post.docsSection,
}; };
}, },
}); });
@@ -384,6 +386,11 @@ export const syncPosts = internalMutation({
newsletter: v.optional(v.boolean()), newsletter: v.optional(v.boolean()),
contactForm: v.optional(v.boolean()), contactForm: v.optional(v.boolean()),
unlisted: v.optional(v.boolean()), unlisted: v.optional(v.boolean()),
docsSection: v.optional(v.boolean()),
docsSectionGroup: v.optional(v.string()),
docsSectionOrder: v.optional(v.number()),
docsSectionGroupOrder: v.optional(v.number()),
docsLanding: v.optional(v.boolean()),
}), }),
), ),
}, },
@@ -435,6 +442,11 @@ export const syncPosts = internalMutation({
newsletter: post.newsletter, newsletter: post.newsletter,
contactForm: post.contactForm, contactForm: post.contactForm,
unlisted: post.unlisted, unlisted: post.unlisted,
docsSection: post.docsSection,
docsSectionGroup: post.docsSectionGroup,
docsSectionOrder: post.docsSectionOrder,
docsSectionGroupOrder: post.docsSectionGroupOrder,
docsLanding: post.docsLanding,
lastSyncedAt: now, lastSyncedAt: now,
}); });
updated++; updated++;
@@ -490,6 +502,11 @@ export const syncPostsPublic = mutation({
newsletter: v.optional(v.boolean()), newsletter: v.optional(v.boolean()),
contactForm: v.optional(v.boolean()), contactForm: v.optional(v.boolean()),
unlisted: v.optional(v.boolean()), unlisted: v.optional(v.boolean()),
docsSection: v.optional(v.boolean()),
docsSectionGroup: v.optional(v.string()),
docsSectionOrder: v.optional(v.number()),
docsSectionGroupOrder: v.optional(v.number()),
docsLanding: v.optional(v.boolean()),
}), }),
), ),
}, },
@@ -541,6 +558,11 @@ export const syncPostsPublic = mutation({
newsletter: post.newsletter, newsletter: post.newsletter,
contactForm: post.contactForm, contactForm: post.contactForm,
unlisted: post.unlisted, unlisted: post.unlisted,
docsSection: post.docsSection,
docsSectionGroup: post.docsSectionGroup,
docsSectionOrder: post.docsSectionOrder,
docsSectionGroupOrder: post.docsSectionGroupOrder,
docsLanding: post.docsLanding,
lastSyncedAt: now, lastSyncedAt: now,
}); });
updated++; updated++;
@@ -874,3 +896,98 @@ export const getPostsByAuthor = query({
})); }));
}, },
}); });
// Get all posts marked for docs section navigation
// Used by DocsSidebar to build the left navigation
export const getDocsPosts = query({
args: {},
returns: v.array(
v.object({
_id: v.id("posts"),
slug: v.string(),
title: v.string(),
docsSectionGroup: v.optional(v.string()),
docsSectionOrder: v.optional(v.number()),
docsSectionGroupOrder: v.optional(v.number()),
}),
),
handler: async (ctx) => {
const posts = await ctx.db
.query("posts")
.withIndex("by_docsSection", (q) => q.eq("docsSection", true))
.collect();
// Filter to only published posts
const publishedDocs = posts.filter((p) => p.published);
// Sort by docsSectionOrder, then by title
const sortedDocs = publishedDocs.sort((a, b) => {
const orderA = a.docsSectionOrder ?? 999;
const orderB = b.docsSectionOrder ?? 999;
if (orderA !== orderB) return orderA - orderB;
return a.title.localeCompare(b.title);
});
return sortedDocs.map((post) => ({
_id: post._id,
slug: post.slug,
title: post.title,
docsSectionGroup: post.docsSectionGroup,
docsSectionOrder: post.docsSectionOrder,
docsSectionGroupOrder: post.docsSectionGroupOrder,
}));
},
});
// Get the docs landing page (post with docsLanding: true)
// Returns null if no landing page is set
export const getDocsLandingPost = query({
args: {},
returns: v.union(
v.object({
_id: v.id("posts"),
slug: v.string(),
title: v.string(),
description: v.string(),
content: v.string(),
date: v.string(),
tags: v.array(v.string()),
readTime: v.optional(v.string()),
image: v.optional(v.string()),
showImageAtTop: v.optional(v.boolean()),
authorName: v.optional(v.string()),
authorImage: v.optional(v.string()),
docsSectionGroup: v.optional(v.string()),
docsSectionOrder: v.optional(v.number()),
}),
v.null(),
),
handler: async (ctx) => {
// Get all docs posts and find one with docsLanding: true
const posts = await ctx.db
.query("posts")
.withIndex("by_docsSection", (q) => q.eq("docsSection", true))
.collect();
const landing = posts.find((p) => p.published && p.docsLanding);
if (!landing) return null;
return {
_id: landing._id,
slug: landing.slug,
title: landing.title,
description: landing.description,
content: landing.content,
date: landing.date,
tags: landing.tags,
readTime: landing.readTime,
image: landing.image,
showImageAtTop: landing.showImageAtTop,
authorName: landing.authorName,
authorImage: landing.authorImage,
docsSectionGroup: landing.docsSectionGroup,
docsSectionOrder: landing.docsSectionOrder,
};
},
});

View File

@@ -29,6 +29,11 @@ export default defineSchema({
newsletter: v.optional(v.boolean()), // Override newsletter signup display (true/false) newsletter: v.optional(v.boolean()), // Override newsletter signup display (true/false)
contactForm: v.optional(v.boolean()), // Enable contact form on this post contactForm: v.optional(v.boolean()), // Enable contact form on this post
unlisted: v.optional(v.boolean()), // Hide from listings but allow direct access via slug unlisted: v.optional(v.boolean()), // Hide from listings but allow direct access via slug
docsSection: v.optional(v.boolean()), // Include in docs navigation
docsSectionGroup: v.optional(v.string()), // Sidebar group name in docs
docsSectionOrder: v.optional(v.number()), // Order within group (lower = first)
docsSectionGroupOrder: v.optional(v.number()), // Order of group itself (lower = first)
docsLanding: v.optional(v.boolean()), // Use as /docs landing page
lastSyncedAt: v.number(), lastSyncedAt: v.number(),
}) })
.index("by_slug", ["slug"]) .index("by_slug", ["slug"])
@@ -37,6 +42,7 @@ export default defineSchema({
.index("by_featured", ["featured"]) .index("by_featured", ["featured"])
.index("by_blogFeatured", ["blogFeatured"]) .index("by_blogFeatured", ["blogFeatured"])
.index("by_authorName", ["authorName"]) .index("by_authorName", ["authorName"])
.index("by_docsSection", ["docsSection"])
.searchIndex("search_content", { .searchIndex("search_content", {
searchField: "content", searchField: "content",
filterFields: ["published"], filterFields: ["published"],
@@ -70,11 +76,17 @@ export default defineSchema({
contactForm: v.optional(v.boolean()), // Enable contact form on this page contactForm: v.optional(v.boolean()), // Enable contact form on this page
newsletter: v.optional(v.boolean()), // Override newsletter signup display (true/false) newsletter: v.optional(v.boolean()), // Override newsletter signup display (true/false)
textAlign: v.optional(v.string()), // Text alignment: "left", "center", "right" (default: "left") textAlign: v.optional(v.string()), // Text alignment: "left", "center", "right" (default: "left")
docsSection: v.optional(v.boolean()), // Include in docs navigation
docsSectionGroup: v.optional(v.string()), // Sidebar group name in docs
docsSectionOrder: v.optional(v.number()), // Order within group (lower = first)
docsSectionGroupOrder: v.optional(v.number()), // Order of group itself (lower = first)
docsLanding: v.optional(v.boolean()), // Use as /docs landing page
lastSyncedAt: v.number(), lastSyncedAt: v.number(),
}) })
.index("by_slug", ["slug"]) .index("by_slug", ["slug"])
.index("by_published", ["published"]) .index("by_published", ["published"])
.index("by_featured", ["featured"]) .index("by_featured", ["featured"])
.index("by_docsSection", ["docsSection"])
.searchIndex("search_content", { .searchIndex("search_content", {
searchField: "content", searchField: "content",
filterFields: ["published"], filterFields: ["published"],

View File

@@ -172,6 +172,10 @@ Markdown files with frontmatter for blog posts. Each file becomes a blog post.
| `newsletter` | Override newsletter signup display (optional, true/false) | | `newsletter` | Override newsletter signup display (optional, true/false) |
| `contactForm` | Enable contact form on this post (optional). Requires siteConfig.contactForm.enabled: true and AGENTMAIL_API_KEY/AGENTMAIL_INBOX environment variables. | | `contactForm` | Enable contact form on this post (optional). Requires siteConfig.contactForm.enabled: true and AGENTMAIL_API_KEY/AGENTMAIL_INBOX environment variables. |
| `unlisted` | Hide from listings but allow direct access via slug (optional). Set `true` to hide from blog listings, featured sections, tag pages, search results, and related posts. Post remains accessible via direct link. | | `unlisted` | Hide from listings but allow direct access via slug (optional). Set `true` to hide from blog listings, featured sections, tag pages, search results, and related posts. Post remains accessible via direct link. |
| `docsSection` | Include in docs sidebar (optional). Set `true` to show in the docs section navigation. |
| `docsSectionGroup` | Group name for docs sidebar (optional). Posts with the same group name appear together. |
| `docsSectionOrder` | Order within docs group (optional). Lower numbers appear first within the group. |
| `docsSectionGroupOrder` | Order of the group in docs sidebar (optional). Lower numbers make the group appear first. Groups without this field sort alphabetically. |
## Static Pages (`content/pages/`) ## Static Pages (`content/pages/`)
@@ -203,6 +207,10 @@ Markdown files for static pages like About, Projects, Contact, Changelog.
| `newsletter` | Override newsletter signup display (optional, true/false) | | `newsletter` | Override newsletter signup display (optional, true/false) |
| `contactForm` | Enable contact form on this page (optional). Requires siteConfig.contactForm.enabled: true and AGENTMAIL_API_KEY/AGENTMAIL_INBOX environment variables. | | `contactForm` | Enable contact form on this page (optional). Requires siteConfig.contactForm.enabled: true and AGENTMAIL_API_KEY/AGENTMAIL_INBOX environment variables. |
| `textAlign` | Text alignment: "left", "center", "right" (optional, default: "left"). Used by home.md for home intro content alignment | | `textAlign` | Text alignment: "left", "center", "right" (optional, default: "left"). Used by home.md for home intro content alignment |
| `docsSection` | Include in docs sidebar (optional). Set `true` to show in the docs section navigation. |
| `docsSectionGroup` | Group name for docs sidebar (optional). Pages with the same group name appear together. |
| `docsSectionOrder` | Order within docs group (optional). Lower numbers appear first within the group. |
| `docsSectionGroupOrder` | Order of the group in docs sidebar (optional). Lower numbers make the group appear first. Groups without this field sort alphabetically. |
## Scripts (`scripts/`) ## Scripts (`scripts/`)

View File

@@ -2,7 +2,7 @@
--- ---
Type: page Type: page
Date: 2026-01-02 Date: 2026-01-03
--- ---
An open-source publishing framework built for AI agents and developers to ship websites, docs, or blogs. Write markdown, sync from the terminal. Your content is instantly available to browsers, LLMs, and AI agents. Built on Convex and Netlify. An open-source publishing framework built for AI agents and developers to ship websites, docs, or blogs. Write markdown, sync from the terminal. Your content is instantly available to browsers, LLMs, and AI agents. Built on Convex and Netlify.

View File

@@ -2,12 +2,41 @@
--- ---
Type: page Type: page
Date: 2026-01-02 Date: 2026-01-03
--- ---
All notable changes to this project. All notable changes to this project.
![](https://img.shields.io/badge/License-MIT-yellow.svg) ![](https://img.shields.io/badge/License-MIT-yellow.svg)
## v2.7.0
Released January 2, 2026
**Docs sidebar group ordering**
- New `docsSectionGroupOrder` frontmatter field for controlling docs sidebar group order
- Groups are sorted by the minimum `docsSectionGroupOrder` value among items in each group
- Lower numbers appear first, groups without this field sort alphabetically
- Works alongside `docsSection`, `docsSectionGroup`, and `docsSectionOrder` fields
**Example usage:**
```yaml
---
docsSection: true
docsSectionGroup: "Getting Started"
docsSectionGroupOrder: 1
docsSectionOrder: 1
---
```
**Technical details:**
- Updated `convex/schema.ts` with `docsSectionGroupOrder` field in posts and pages tables
- Updated `convex/posts.ts` and `convex/pages.ts` queries and mutations
- Updated `scripts/sync-posts.ts` to parse `docsSectionGroupOrder` from frontmatter
- Updated `src/components/DocsSidebar.tsx` to sort groups by `docsSectionGroupOrder`
## v2.6.0 ## v2.6.0
Released January 1, 2026 Released January 1, 2026

View File

@@ -2,7 +2,7 @@
--- ---
Type: page Type: page
Date: 2026-01-02 Date: 2026-01-03
--- ---
You found the contact page. Nice You found the contact page. Nice

View File

@@ -1,8 +1,8 @@
# Docs # Documentation
--- ---
Type: page Type: page
Date: 2026-01-02 Date: 2026-01-03
--- ---
## Getting Started ## Getting Started
@@ -126,6 +126,10 @@ Content here...
| `newsletter` | No | Override newsletter signup display (`true` to show, `false` to hide) | | `newsletter` | No | Override newsletter signup display (`true` to show, `false` to hide) |
| `contactForm` | No | Enable contact form on this post | | `contactForm` | No | Enable contact form on this post |
| `unlisted` | No | Hide from listings but allow direct access via slug. Set `true` to hide from blog listings, featured sections, tag pages, search results, and related posts. Post remains accessible via direct link. | | `unlisted` | No | Hide from listings but allow direct access via slug. Set `true` to hide from blog listings, featured sections, tag pages, search results, and related posts. Post remains accessible via direct link. |
| `docsSection` | No | Include in docs sidebar. Set `true` to show in the docs section navigation. |
| `docsSectionGroup` | No | Group name for docs sidebar. Posts with the same group name appear together. |
| `docsSectionOrder` | No | Order within docs group. Lower numbers appear first within the group. |
| `docsSectionGroupOrder` | No | Order of the group in docs sidebar. Lower numbers make the group appear first. Groups without this field sort alphabetically. |
| `showImageAtTop` | No | Set `true` to display the `image` field at the top of the post above the header (default: `false`) | | `showImageAtTop` | No | Set `true` to display the `image` field at the top of the post above the header (default: `false`) |
### Static pages ### Static pages
@@ -169,6 +173,10 @@ Content here...
| `contactForm` | No | Enable contact form on this page | | `contactForm` | No | Enable contact form on this page |
| `showImageAtTop` | No | Set `true` to display the `image` field at the top of the page above the header (default: `false`) | | `showImageAtTop` | No | Set `true` to display the `image` field at the top of the page above the header (default: `false`) |
| `textAlign` | No | Text alignment: "left" (default), "center", or "right". Used by `home.md` for home intro alignment | | `textAlign` | No | Text alignment: "left" (default), "center", or "right". Used by `home.md` for home intro alignment |
| `docsSection` | No | Include in docs sidebar. Set `true` to show in the docs section navigation. |
| `docsSectionGroup` | No | Group name for docs sidebar. Pages with the same group name appear together. |
| `docsSectionOrder` | No | Order within docs group. Lower numbers appear first within the group. |
| `docsSectionGroupOrder` | No | Order of the group in docs sidebar. Lower numbers make the group appear first. Groups without this field sort alphabetically. |
**Hide pages from navigation:** Set `showInNav: false` to keep a page published and accessible via direct URL, but hidden from the navigation menu. Pages with `showInNav: false` remain searchable and available via API endpoints. Useful for pages you want to link directly but not show in the main nav. **Hide pages from navigation:** Set `showInNav: false` to keep a page published and accessible via direct URL, but hidden from the navigation menu. Pages with `showInNav: false` remain searchable and available via API endpoints. Useful for pages you want to link directly but not show in the main nav.
@@ -1113,10 +1121,10 @@ The Dashboard includes a dedicated AI Agent section with tab-based UI for Chat a
**Environment Variables (Convex):** **Environment Variables (Convex):**
| Variable | Description | | Variable | Description |
| --- | --- | | ------------------- | -------------------------------------------------- |
| `ANTHROPIC_API_KEY` | Required for Claude Sonnet 4 | | `ANTHROPIC_API_KEY` | Required for Claude Sonnet 4 |
| `OPENAI_API_KEY` | Required for GPT-4o | | `OPENAI_API_KEY` | Required for GPT-4o |
| `GOOGLE_AI_API_KEY` | Required for Gemini 2.0 Flash and image generation | | `GOOGLE_AI_API_KEY` | Required for Gemini 2.0 Flash and image generation |
**Note:** Only configure the API keys for models you want to use. If a key is not set, users see a helpful setup message when they try to use that model. **Note:** Only configure the API keys for models you want to use. If a key is not set, users see a helpful setup message when they try to use that model.

View File

@@ -2,7 +2,7 @@
--- ---
Type: page Type: page
Date: 2026-01-02 Date: 2026-01-03
--- ---
Built with [Convex](https://convex.dev) for real-time sync and deployed on [Netlify](https://netlify.com). Read the [project on GitHub](https://github.com/waynesutton/markdown-site) to fork and deploy your own. View [real-time site stats](/stats). Built with [Convex](https://convex.dev) for real-time sync and deployed on [Netlify](https://netlify.com). Read the [project on GitHub](https://github.com/waynesutton/markdown-site) to fork and deploy your own. View [real-time site stats](/stats).

View File

@@ -91,19 +91,19 @@ If you prefer to update files manually, follow the guide in `FORK_CONFIG.md`. It
The configuration script updates these files: The configuration script updates these files:
| File | What changes | | File | What changes |
| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------ | | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `src/config/siteConfig.ts` | Site name, bio, GitHub username, gitHubRepo config, features (logo gallery, GitHub contributions, visitor map, blog page, posts display, homepage, right sidebar, footer, social footer, AI chat, newsletter, contact form, newsletter admin, stats page, MCP server, dashboard, image lightbox) | | `src/config/siteConfig.ts` | Site name, bio, GitHub username, gitHubRepo config, features (logo gallery, GitHub contributions, visitor map, blog page, posts display, homepage, right sidebar, footer, social footer, AI chat, newsletter, contact form, newsletter admin, stats page, MCP server, dashboard, image lightbox) |
| `src/pages/Home.tsx` | Intro paragraph, footer links | | `src/pages/Home.tsx` | Intro paragraph, footer links |
| `src/pages/Post.tsx` | SITE_URL, SITE_NAME constants | | `src/pages/Post.tsx` | SITE_URL, SITE_NAME constants |
| `convex/http.ts` | SITE_URL, SITE_NAME constants | | `convex/http.ts` | SITE_URL, SITE_NAME constants |
| `convex/rss.ts` | SITE_URL, SITE_TITLE, SITE_DESCRIPTION | | `convex/rss.ts` | SITE_URL, SITE_TITLE, SITE_DESCRIPTION |
| `index.html` | Meta tags, JSON-LD, page title | | `index.html` | Meta tags, JSON-LD, page title |
| `public/llms.txt` | Site info, GitHub link | | `public/llms.txt` | Site info, GitHub link |
| `public/robots.txt` | Sitemap URL | | `public/robots.txt` | Sitemap URL |
| `public/openapi.yaml` | Server URL, site name | | `public/openapi.yaml` | Server URL, site name |
| `public/.well-known/ai-plugin.json` | Plugin metadata | | `public/.well-known/ai-plugin.json` | Plugin metadata |
| `src/context/ThemeContext.tsx` | Default theme | | `src/context/ThemeContext.tsx` | Default theme |
## Optional settings ## Optional settings

View File

@@ -2,7 +2,7 @@
--- ---
Type: page Type: page
Date: 2026-01-02 Date: 2026-01-03
--- ---
An open-source publishing framework built for AI agents and developers to ship **[docs](/docs)**, or **[blogs](/blog)** or **[websites](/)**. An open-source publishing framework built for AI agents and developers to ship **[docs](/docs)**, or **[blogs](/blog)** or **[websites](/)**.

View File

@@ -9,6 +9,8 @@ Reading time: 3 min read
Tags: tutorial, markdown, cursor, IDE, publishing Tags: tutorial, markdown, cursor, IDE, publishing
--- ---
---
# How to Publish a Blog Post # How to Publish a Blog Post
![nature](/images/matthew-smith-Rfflri94rs8-unsplash.jpg) ![nature](/images/matthew-smith-Rfflri94rs8-unsplash.jpg)

View File

@@ -45,7 +45,7 @@ This is the homepage index of all published content.
- **[Footer](/raw/footer.md)** - **[Footer](/raw/footer.md)**
- **[Home Intro](/raw/home-intro.md)** - **[Home Intro](/raw/home-intro.md)**
- **[Docs](/raw/docs.md)** - **[Documentation](/raw/documentation.md)**
- **[About](/raw/about.md)** - An open-source publishing framework built for AI agents and developers to ship websites, docs, or blogs. - **[About](/raw/about.md)** - An open-source publishing framework built for AI agents and developers to ship websites, docs, or blogs.
- **[Projects](/raw/projects.md)** - **[Projects](/raw/projects.md)**
- **[Contact](/raw/contact.md)** - **[Contact](/raw/contact.md)**

View File

@@ -341,7 +341,8 @@ Embed a YouTube video using an iframe:
height="315" height="315"
src="https://www.youtube.com/embed/dQw4w9WgXcQ" src="https://www.youtube.com/embed/dQw4w9WgXcQ"
title="YouTube video" title="YouTube video"
allowfullscreen> allowfullscreen
>
</iframe> </iframe>
``` ```
@@ -357,7 +358,8 @@ Embed a tweet using the Twitter embed URL:
<iframe <iframe
src="https://platform.twitter.com/embed/Tweet.html?id=20" src="https://platform.twitter.com/embed/Tweet.html?id=20"
width="550" width="550"
height="250"> height="250"
>
</iframe> </iframe>
``` ```
@@ -375,7 +377,8 @@ Use `youtube-nocookie.com` for privacy-enhanced embeds:
height="315" height="315"
src="https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ" src="https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ"
title="YouTube video" title="YouTube video"
allowfullscreen> allowfullscreen
>
</iframe> </iframe>
``` ```

View File

@@ -2,7 +2,7 @@
--- ---
Type: page Type: page
Date: 2026-01-02 Date: 2026-01-03
--- ---
# Newsletter Demo Page # Newsletter Demo Page

View File

@@ -2,7 +2,7 @@
--- ---
Type: page Type: page
Date: 2026-01-02 Date: 2026-01-03
--- ---
This markdown framework is open source and built to be extended. Here is what ships out of the box. This markdown framework is open source and built to be extended. Here is what ships out of the box.

View File

@@ -56,6 +56,7 @@ This guide walks you through forking [this markdown framework](https://github.co
- [Visitor Map](#visitor-map) - [Visitor Map](#visitor-map)
- [Logo Gallery](#logo-gallery) - [Logo Gallery](#logo-gallery)
- [Blog page](#blog-page) - [Blog page](#blog-page)
- [Homepage Post Limit](#homepage-post-limit)
- [Hardcoded Navigation Items](#hardcoded-navigation-items) - [Hardcoded Navigation Items](#hardcoded-navigation-items)
- [Scroll-to-top button](#scroll-to-top-button) - [Scroll-to-top button](#scroll-to-top-button)
- [Change the Default Theme](#change-the-default-theme) - [Change the Default Theme](#change-the-default-theme)
@@ -64,10 +65,15 @@ This guide walks you through forking [this markdown framework](https://github.co
- [Add Static Pages (Optional)](#add-static-pages-optional) - [Add Static Pages (Optional)](#add-static-pages-optional)
- [Update SEO Meta Tags](#update-seo-meta-tags) - [Update SEO Meta Tags](#update-seo-meta-tags)
- [Update llms.txt and robots.txt](#update-llmstxt-and-robotstxt) - [Update llms.txt and robots.txt](#update-llmstxt-and-robotstxt)
- [Tag Pages and Related Posts](#tag-pages-and-related-posts)
- [Search](#search) - [Search](#search)
- [Using Search](#using-search) - [Using Search](#using-search)
- [How It Works](#how-it-works) - [How It Works](#how-it-works)
- [Real-time Stats](#real-time-stats) - [Real-time Stats](#real-time-stats)
- [Footer Configuration](#footer-configuration)
- [Social Footer Configuration](#social-footer-configuration)
- [Right Sidebar Configuration](#right-sidebar-configuration)
- [Contact Form Configuration](#contact-form-configuration)
- [Newsletter Admin](#newsletter-admin) - [Newsletter Admin](#newsletter-admin)
- [Mobile Navigation](#mobile-navigation) - [Mobile Navigation](#mobile-navigation)
- [Copy Page Dropdown](#copy-page-dropdown) - [Copy Page Dropdown](#copy-page-dropdown)
@@ -80,7 +86,9 @@ This guide walks you through forking [this markdown framework](https://github.co
- [Project Structure](#project-structure) - [Project Structure](#project-structure)
- [Write Page](#write-page) - [Write Page](#write-page)
- [AI Agent chat](#ai-agent-chat) - [AI Agent chat](#ai-agent-chat)
- [Dashboard](#dashboard)
- [Next Steps](#next-steps) - [Next Steps](#next-steps)
- [MCP Server](#mcp-server)
## Prerequisites ## Prerequisites
@@ -329,23 +337,27 @@ Your markdown content here...
### Frontmatter Fields ### Frontmatter Fields
| Field | Required | Description | | Field | Required | Description |
| --------------- | -------- | ----------------------------------------------------------------------------- | | --------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `title` | Yes | Post title | | `title` | Yes | Post title |
| `description` | Yes | Short description for SEO | | `description` | Yes | Short description for SEO |
| `date` | Yes | Publication date (YYYY-MM-DD) | | `date` | Yes | Publication date (YYYY-MM-DD) |
| `slug` | Yes | URL path (must be unique) | | `slug` | Yes | URL path (must be unique) |
| `published` | Yes | Set to `true` to publish | | `published` | Yes | Set to `true` to publish |
| `tags` | Yes | Array of topic tags | | `tags` | Yes | Array of topic tags |
| `readTime` | No | Estimated reading time | | `readTime` | No | Estimated reading time |
| `image` | No | Header/Open Graph image URL | | `image` | No | Header/Open Graph image URL |
| `excerpt` | No | Short excerpt for card view | | `excerpt` | No | Short excerpt for card view |
| `featured` | No | Set `true` to show in featured section | | `featured` | No | Set `true` to show in featured section |
| `featuredOrder` | No | Order in featured section (lower = first) | | `featuredOrder` | No | Order in featured section (lower = first) |
| `authorName` | No | Author display name shown next to date | | `authorName` | No | Author display name shown next to date |
| `authorImage` | No | Round author avatar image URL | | `authorImage` | No | Round author avatar image URL |
| `rightSidebar` | No | Enable right sidebar with CopyPageDropdown (opt-in, requires explicit `true`) | | `rightSidebar` | No | Enable right sidebar with CopyPageDropdown (opt-in, requires explicit `true`) |
| `unlisted` | No | Hide from listings but allow direct access via slug. Set `true` to hide from blog listings, featured sections, tag pages, search results, and related posts. Post remains accessible via direct link. | | `unlisted` | No | Hide from listings but allow direct access via slug. Set `true` to hide from blog listings, featured sections, tag pages, search results, and related posts. Post remains accessible via direct link. |
| `docsSection` | No | Include in docs sidebar. Set `true` to show in the docs section navigation. |
| `docsSectionGroup` | No | Group name for docs sidebar. Posts with the same group name appear together. |
| `docsSectionOrder` | No | Order within docs group. Lower numbers appear first within the group. |
| `docsSectionGroupOrder` | No | Order of the group in docs sidebar. Lower numbers make the group appear first. Groups without this field sort alphabetically. |
### How Frontmatter Works ### How Frontmatter Works
@@ -1584,11 +1596,11 @@ The Dashboard includes a dedicated AI Agent section with a tab-based UI for Chat
Agent requires API keys for the providers you want to use. Set these in Convex environment variables: Agent requires API keys for the providers you want to use. Set these in Convex environment variables:
| Variable | Provider | Features | | Variable | Provider | Features |
| --- | --- | --- | | ------------------- | --------- | ---------------------------------------- |
| `ANTHROPIC_API_KEY` | Anthropic | Claude Sonnet 4 chat | | `ANTHROPIC_API_KEY` | Anthropic | Claude Sonnet 4 chat |
| `OPENAI_API_KEY` | OpenAI | GPT-4o chat | | `OPENAI_API_KEY` | OpenAI | GPT-4o chat |
| `GOOGLE_AI_API_KEY` | Google | Gemini 2.0 Flash chat + image generation | | `GOOGLE_AI_API_KEY` | Google | Gemini 2.0 Flash chat + image generation |
**Optional system prompt variables:** **Optional system prompt variables:**
@@ -1673,6 +1685,7 @@ npm run sync-server
``` ```
This starts a local HTTP server on `localhost:3001` that: This starts a local HTTP server on `localhost:3001` that:
- Executes sync commands when requested from the dashboard - Executes sync commands when requested from the dashboard
- Streams output in real-time to the dashboard terminal view - Streams output in real-time to the dashboard terminal view
- Shows server status (online/offline) in the dashboard - Shows server status (online/offline) in the dashboard

View File

@@ -46,6 +46,11 @@ interface PostFrontmatter {
blogFeatured?: boolean; // Show as hero featured post on /blog page blogFeatured?: boolean; // Show as hero featured post on /blog page
newsletter?: boolean; // Override newsletter signup display (true/false) newsletter?: boolean; // Override newsletter signup display (true/false)
contactForm?: boolean; // Enable contact form on this post contactForm?: boolean; // Enable contact form on this post
docsSection?: boolean; // Include in docs navigation
docsSectionGroup?: string; // Sidebar group name in docs
docsSectionOrder?: number; // Order within group (lower = first)
docsSectionGroupOrder?: number; // Order of group itself (lower = first)
docsLanding?: boolean; // Use as /docs landing page
} }
interface ParsedPost { interface ParsedPost {
@@ -74,6 +79,11 @@ interface ParsedPost {
newsletter?: boolean; // Override newsletter signup display (true/false) newsletter?: boolean; // Override newsletter signup display (true/false)
contactForm?: boolean; // Enable contact form on this post contactForm?: boolean; // Enable contact form on this post
unlisted?: boolean; // Hide from listings but allow direct access via slug unlisted?: boolean; // Hide from listings but allow direct access via slug
docsSection?: boolean; // Include in docs navigation
docsSectionGroup?: string; // Sidebar group name in docs
docsSectionOrder?: number; // Order within group (lower = first)
docsSectionGroupOrder?: number; // Order of group itself (lower = first)
docsLanding?: boolean; // Use as /docs landing page
} }
// Page frontmatter (for static pages like About, Projects, Contact) // Page frontmatter (for static pages like About, Projects, Contact)
@@ -99,6 +109,11 @@ interface PageFrontmatter {
contactForm?: boolean; // Enable contact form on this page contactForm?: boolean; // Enable contact form on this page
newsletter?: boolean; // Override newsletter signup display (true/false) newsletter?: boolean; // Override newsletter signup display (true/false)
textAlign?: string; // Text alignment: "left", "center", "right" (default: "left") textAlign?: string; // Text alignment: "left", "center", "right" (default: "left")
docsSection?: boolean; // Include in docs navigation
docsSectionGroup?: string; // Sidebar group name in docs
docsSectionOrder?: number; // Order within group (lower = first)
docsSectionGroupOrder?: number; // Order of group itself (lower = first)
docsLanding?: boolean; // Use as /docs landing page
} }
interface ParsedPage { interface ParsedPage {
@@ -124,6 +139,11 @@ interface ParsedPage {
contactForm?: boolean; // Enable contact form on this page contactForm?: boolean; // Enable contact form on this page
newsletter?: boolean; // Override newsletter signup display (true/false) newsletter?: boolean; // Override newsletter signup display (true/false)
textAlign?: string; // Text alignment: "left", "center", "right" (default: "left") textAlign?: string; // Text alignment: "left", "center", "right" (default: "left")
docsSection?: boolean; // Include in docs navigation
docsSectionGroup?: string; // Sidebar group name in docs
docsSectionOrder?: number; // Order within group (lower = first)
docsSectionGroupOrder?: number; // Order of group itself (lower = first)
docsLanding?: boolean; // Use as /docs landing page
} }
// Calculate reading time based on word count // Calculate reading time based on word count
@@ -174,6 +194,11 @@ function parseMarkdownFile(filePath: string): ParsedPost | null {
newsletter: frontmatter.newsletter, // Override newsletter signup display newsletter: frontmatter.newsletter, // Override newsletter signup display
contactForm: frontmatter.contactForm, // Enable contact form on this post contactForm: frontmatter.contactForm, // Enable contact form on this post
unlisted: frontmatter.unlisted, // Hide from listings but allow direct access unlisted: frontmatter.unlisted, // Hide from listings but allow direct access
docsSection: frontmatter.docsSection, // Include in docs navigation
docsSectionGroup: frontmatter.docsSectionGroup, // Sidebar group name
docsSectionOrder: frontmatter.docsSectionOrder, // Order within group
docsSectionGroupOrder: frontmatter.docsSectionGroupOrder, // Order of group itself
docsLanding: frontmatter.docsLanding, // Use as docs landing page
}; };
} catch (error) { } catch (error) {
console.error(`Error parsing ${filePath}:`, error); console.error(`Error parsing ${filePath}:`, error);
@@ -234,6 +259,11 @@ function parsePageFile(filePath: string): ParsedPage | null {
contactForm: frontmatter.contactForm, // Enable contact form on this page contactForm: frontmatter.contactForm, // Enable contact form on this page
newsletter: frontmatter.newsletter, // Override newsletter signup display newsletter: frontmatter.newsletter, // Override newsletter signup display
textAlign: frontmatter.textAlign, // Text alignment: "left", "center", "right" textAlign: frontmatter.textAlign, // Text alignment: "left", "center", "right"
docsSection: frontmatter.docsSection, // Include in docs navigation
docsSectionGroup: frontmatter.docsSectionGroup, // Sidebar group name
docsSectionOrder: frontmatter.docsSectionOrder, // Order within group
docsSectionGroupOrder: frontmatter.docsSectionGroupOrder, // Order of group itself
docsLanding: frontmatter.docsLanding, // Use as docs landing page
}; };
} catch (error) { } catch (error) {
console.error(`Error parsing page ${filePath}:`, error); console.error(`Error parsing page ${filePath}:`, error);

View File

@@ -3,6 +3,7 @@ import Home from "./pages/Home";
import Post from "./pages/Post"; import Post from "./pages/Post";
import Stats from "./pages/Stats"; import Stats from "./pages/Stats";
import Blog from "./pages/Blog"; import Blog from "./pages/Blog";
import DocsPage from "./pages/DocsPage";
import Write from "./pages/Write"; import Write from "./pages/Write";
import TagPage from "./pages/TagPage"; import TagPage from "./pages/TagPage";
import AuthorPage from "./pages/AuthorPage"; import AuthorPage from "./pages/AuthorPage";
@@ -86,6 +87,13 @@ function App() {
{siteConfig.blogPage.enabled && ( {siteConfig.blogPage.enabled && (
<Route path="/blog" element={<Blog />} /> <Route path="/blog" element={<Blog />} />
)} )}
{/* Docs page route - only enabled when docsSection.enabled is true */}
{siteConfig.docsSection?.enabled && (
<Route
path={`/${siteConfig.docsSection.slug}`}
element={<DocsPage />}
/>
)}
{/* Tag page route - displays posts filtered by tag */} {/* Tag page route - displays posts filtered by tag */}
<Route path="/tags/:tag" element={<TagPage />} /> <Route path="/tags/:tag" element={<TagPage />} />
{/* Author page route - displays posts by a specific author */} {/* Author page route - displays posts by a specific author */}

View File

@@ -34,11 +34,17 @@ const sanitizeSchema = {
div: ["style"], // Allow inline styles on div for grid layouts div: ["style"], // Allow inline styles on div for grid layouts
p: ["style"], // Allow inline styles on p elements p: ["style"], // Allow inline styles on p elements
a: ["style", "href", "target", "rel"], // Allow inline styles on links a: ["style", "href", "target", "rel"], // Allow inline styles on links
img: [ img: [...(defaultSchema.attributes?.img || []), "style"], // Allow inline styles on images
...(defaultSchema.attributes?.img || []), iframe: [
"src",
"width",
"height",
"allow",
"allowfullscreen",
"frameborder",
"title",
"style", "style",
], // Allow inline styles on images ], // Allow iframe with specific attributes
iframe: ["src", "width", "height", "allow", "allowfullscreen", "frameborder", "title", "style"], // Allow iframe with specific attributes
}, },
}; };
@@ -350,20 +356,26 @@ function stripHtmlComments(content: string): string {
newsletter: "___NEWSLETTER_PLACEHOLDER___", newsletter: "___NEWSLETTER_PLACEHOLDER___",
contactform: "___CONTACTFORM_PLACEHOLDER___", contactform: "___CONTACTFORM_PLACEHOLDER___",
}; };
let processed = content; let processed = content;
// Replace special placeholders with markers // Replace special placeholders with markers
processed = processed.replace(/<!--\s*newsletter\s*-->/gi, markers.newsletter); processed = processed.replace(
processed = processed.replace(/<!--\s*contactform\s*-->/gi, markers.contactform); /<!--\s*newsletter\s*-->/gi,
markers.newsletter,
);
processed = processed.replace(
/<!--\s*contactform\s*-->/gi,
markers.contactform,
);
// Remove all remaining HTML comments (including multi-line) // Remove all remaining HTML comments (including multi-line)
processed = processed.replace(/<!--[\s\S]*?-->/g, ""); processed = processed.replace(/<!--[\s\S]*?-->/g, "");
// Restore special placeholders // Restore special placeholders
processed = processed.replace(markers.newsletter, "<!-- newsletter -->"); processed = processed.replace(markers.newsletter, "<!-- newsletter -->");
processed = processed.replace(markers.contactform, "<!-- contactform -->"); processed = processed.replace(markers.contactform, "<!-- contactform -->");
return processed; return processed;
} }
@@ -371,13 +383,13 @@ function stripHtmlComments(content: string): string {
// Supports: <!-- newsletter --> and <!-- contactform --> // Supports: <!-- newsletter --> and <!-- contactform -->
function parseContentForEmbeds(content: string): ContentSegment[] { function parseContentForEmbeds(content: string): ContentSegment[] {
const segments: ContentSegment[] = []; const segments: ContentSegment[] = [];
// Pattern matches <!-- newsletter --> or <!-- contactform --> (case insensitive) // Pattern matches <!-- newsletter --> or <!-- contactform --> (case insensitive)
const pattern = /<!--\s*(newsletter|contactform)\s*-->/gi; const pattern = /<!--\s*(newsletter|contactform)\s*-->/gi;
let lastIndex = 0; let lastIndex = 0;
let match: RegExpExecArray | null; let match: RegExpExecArray | null;
while ((match = pattern.exec(content)) !== null) { while ((match = pattern.exec(content)) !== null) {
// Add content before the placeholder // Add content before the placeholder
if (match.index > lastIndex) { if (match.index > lastIndex) {
@@ -386,7 +398,7 @@ function parseContentForEmbeds(content: string): ContentSegment[] {
segments.push({ type: "content", value: textBefore }); segments.push({ type: "content", value: textBefore });
} }
} }
// Add the embed placeholder // Add the embed placeholder
const embedType = match[1].toLowerCase(); const embedType = match[1].toLowerCase();
if (embedType === "newsletter") { if (embedType === "newsletter") {
@@ -394,10 +406,10 @@ function parseContentForEmbeds(content: string): ContentSegment[] {
} else if (embedType === "contactform") { } else if (embedType === "contactform") {
segments.push({ type: "contactform" }); segments.push({ type: "contactform" });
} }
lastIndex = match.index + match[0].length; lastIndex = match.index + match[0].length;
} }
// Add remaining content after last placeholder // Add remaining content after last placeholder
if (lastIndex < content.length) { if (lastIndex < content.length) {
const remaining = content.slice(lastIndex); const remaining = content.slice(lastIndex);
@@ -405,12 +417,12 @@ function parseContentForEmbeds(content: string): ContentSegment[] {
segments.push({ type: "content", value: remaining }); segments.push({ type: "content", value: remaining });
} }
} }
// If no placeholders found, return single content segment // If no placeholders found, return single content segment
if (segments.length === 0) { if (segments.length === 0) {
segments.push({ type: "content", value: content }); segments.push({ type: "content", value: content });
} }
return segments; return segments;
} }
@@ -459,9 +471,16 @@ function HeadingAnchor({ id }: { id: string }) {
); );
} }
export default function BlogPost({ content, slug, pageType = "post" }: BlogPostProps) { export default function BlogPost({
content,
slug,
pageType = "post",
}: BlogPostProps) {
const { theme } = useTheme(); const { theme } = useTheme();
const [lightboxImage, setLightboxImage] = useState<{ src: string; alt: string } | null>(null); const [lightboxImage, setLightboxImage] = useState<{
src: string;
alt: string;
} | null>(null);
const isLightboxEnabled = siteConfig.imageLightbox?.enabled !== false; const isLightboxEnabled = siteConfig.imageLightbox?.enabled !== false;
const getCodeTheme = () => { const getCodeTheme = () => {
@@ -479,7 +498,7 @@ export default function BlogPost({ content, slug, pageType = "post" }: BlogPostP
// Strip HTML comments (except special placeholders) before processing // Strip HTML comments (except special placeholders) before processing
const cleanedContent = stripHtmlComments(content); const cleanedContent = stripHtmlComments(content);
// Parse content for inline embeds // Parse content for inline embeds
const segments = parseContentForEmbeds(cleanedContent); const segments = parseContentForEmbeds(cleanedContent);
const hasInlineEmbeds = segments.some((s) => s.type !== "content"); const hasInlineEmbeds = segments.some((s) => s.type !== "content");
@@ -492,21 +511,25 @@ export default function BlogPost({ content, slug, pageType = "post" }: BlogPostP
rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema]]} rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema]]}
components={{ components={{
code(codeProps) { code(codeProps) {
const { className, children, node, style, ...restProps } = codeProps as { const { className, children, node, style, ...restProps } =
className?: string; codeProps as {
children?: React.ReactNode; className?: string;
node?: { tagName?: string; properties?: { className?: string[] } }; children?: React.ReactNode;
style?: React.CSSProperties; node?: {
inline?: boolean; tagName?: string;
}; properties?: { className?: string[] };
};
style?: React.CSSProperties;
inline?: boolean;
};
const match = /language-(\w+)/.exec(className || ""); const match = /language-(\w+)/.exec(className || "");
// Detect inline code: no language class AND content is short without newlines // Detect inline code: no language class AND content is short without newlines
const codeContent = String(children); const codeContent = String(children);
const hasNewlines = codeContent.includes('\n'); const hasNewlines = codeContent.includes("\n");
const isShort = codeContent.length < 80; const isShort = codeContent.length < 80;
const hasLanguage = !!match || !!className; const hasLanguage = !!match || !!className;
// It's inline only if: no language, short content, no newlines // It's inline only if: no language, short content, no newlines
const isInline = !hasLanguage && isShort && !hasNewlines; const isInline = !hasLanguage && isShort && !hasNewlines;
@@ -521,16 +544,20 @@ export default function BlogPost({ content, slug, pageType = "post" }: BlogPostP
const codeString = String(children).replace(/\n$/, ""); const codeString = String(children).replace(/\n$/, "");
const language = match ? match[1] : "text"; const language = match ? match[1] : "text";
const isTextBlock = language === "text"; const isTextBlock = language === "text";
// Custom styles for text blocks to enable wrapping // Custom styles for text blocks to enable wrapping
const textBlockStyle = isTextBlock ? { const textBlockStyle = isTextBlock
whiteSpace: "pre-wrap" as const, ? {
wordWrap: "break-word" as const, whiteSpace: "pre-wrap" as const,
overflowWrap: "break-word" as const, wordWrap: "break-word" as const,
} : {}; overflowWrap: "break-word" as const,
}
: {};
return ( return (
<div className={`code-block-wrapper ${isTextBlock ? "code-block-text" : ""}`}> <div
className={`code-block-wrapper ${isTextBlock ? "code-block-text" : ""}`}
>
{match && <span className="code-language">{match[1]}</span>} {match && <span className="code-language">{match[1]}</span>}
<CodeCopyButton code={codeString} /> <CodeCopyButton code={codeString} />
<SyntaxHighlighter <SyntaxHighlighter
@@ -538,7 +565,9 @@ export default function BlogPost({ content, slug, pageType = "post" }: BlogPostP
language={language} language={language}
PreTag="div" PreTag="div"
customStyle={textBlockStyle} customStyle={textBlockStyle}
codeTagProps={isTextBlock ? { style: textBlockStyle } : undefined} codeTagProps={
isTextBlock ? { style: textBlockStyle } : undefined
}
> >
{codeString} {codeString}
</SyntaxHighlighter> </SyntaxHighlighter>
@@ -681,7 +710,7 @@ export default function BlogPost({ content, slug, pageType = "post" }: BlogPostP
const url = new URL(src); const url = new URL(src);
const isAllowed = ALLOWED_IFRAME_DOMAINS.some( const isAllowed = ALLOWED_IFRAME_DOMAINS.some(
(domain) => (domain) =>
url.hostname === domain || url.hostname.endsWith("." + domain) url.hostname === domain || url.hostname.endsWith("." + domain),
); );
if (!isAllowed) return null; if (!isAllowed) return null;
@@ -727,10 +756,7 @@ export default function BlogPost({ content, slug, pageType = "post" }: BlogPostP
if (segment.type === "contactform") { if (segment.type === "contactform") {
// Contact form inline // Contact form inline
return siteConfig.contactForm?.enabled ? ( return siteConfig.contactForm?.enabled ? (
<ContactForm <ContactForm key={`contactform-${index}`} source={source} />
key={`contactform-${index}`}
source={source}
/>
) : null; ) : null;
} }
// Markdown content segment // Markdown content segment
@@ -756,214 +782,227 @@ export default function BlogPost({ content, slug, pageType = "post" }: BlogPostP
remarkPlugins={[remarkGfm, remarkBreaks]} remarkPlugins={[remarkGfm, remarkBreaks]}
rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema]]} rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema]]}
components={{ components={{
code(codeProps) { code(codeProps) {
const { className, children, node, style, ...restProps } = codeProps as { const { className, children, node, style, ...restProps } =
className?: string; codeProps as {
children?: React.ReactNode; className?: string;
node?: { tagName?: string; properties?: { className?: string[] } }; children?: React.ReactNode;
style?: React.CSSProperties; node?: {
inline?: boolean; tagName?: string;
}; properties?: { className?: string[] };
const match = /language-(\w+)/.exec(className || ""); };
style?: React.CSSProperties;
// Detect inline code: no language class AND content is short without newlines inline?: boolean;
// Fenced code blocks (even without language) are longer or have structure };
const codeContent = String(children); const match = /language-(\w+)/.exec(className || "");
const hasNewlines = codeContent.includes('\n');
const isShort = codeContent.length < 80;
const hasLanguage = !!match || !!className;
// It's inline only if: no language, short content, no newlines
const isInline = !hasLanguage && isShort && !hasNewlines;
if (isInline) { // Detect inline code: no language class AND content is short without newlines
return ( // Fenced code blocks (even without language) are longer or have structure
<code className="inline-code" style={style} {...restProps}> const codeContent = String(children);
{children} const hasNewlines = codeContent.includes("\n");
</code> const isShort = codeContent.length < 80;
); const hasLanguage = !!match || !!className;
}
const codeString = String(children).replace(/\n$/, ""); // It's inline only if: no language, short content, no newlines
const language = match ? match[1] : "text"; const isInline = !hasLanguage && isShort && !hasNewlines;
const isTextBlock = language === "text";
if (isInline) {
// Custom styles for text blocks to enable wrapping return (
const textBlockStyle = isTextBlock ? { <code className="inline-code" style={style} {...restProps}>
whiteSpace: "pre-wrap" as const, {children}
wordWrap: "break-word" as const, </code>
overflowWrap: "break-word" as const, );
} : {};
return (
<div className={`code-block-wrapper ${isTextBlock ? "code-block-text" : ""}`}>
{match && <span className="code-language">{match[1]}</span>}
<CodeCopyButton code={codeString} />
<SyntaxHighlighter
style={getCodeTheme()}
language={language}
PreTag="div"
customStyle={textBlockStyle}
codeTagProps={isTextBlock ? { style: textBlockStyle } : undefined}
>
{codeString}
</SyntaxHighlighter>
</div>
);
},
img({ src, alt }) {
const handleImageClick = () => {
if (isLightboxEnabled && src) {
setLightboxImage({ src, alt: alt || "" });
} }
};
return (
<span className="blog-image-wrapper">
<img
src={src}
alt={alt || ""}
className={`blog-image ${isLightboxEnabled ? "blog-image-clickable" : ""}`}
loading="lazy"
onClick={isLightboxEnabled ? handleImageClick : undefined}
style={isLightboxEnabled ? { cursor: "pointer" } : undefined}
/>
{alt && <span className="blog-image-caption">{alt}</span>}
</span>
);
},
a({ href, children }) {
const isExternal = href?.startsWith("http");
return (
<a
href={href}
target={isExternal ? "_blank" : undefined}
rel={isExternal ? "noopener noreferrer" : undefined}
className="blog-link"
>
{children}
</a>
);
},
blockquote({ children }) {
return (
<blockquote className="blog-blockquote">{children}</blockquote>
);
},
h1({ children }) {
const id = generateSlug(getTextContent(children));
return (
<h1 id={id} className="blog-h1">
<HeadingAnchor id={id} />
{children}
</h1>
);
},
h2({ children }) {
const id = generateSlug(getTextContent(children));
return (
<h2 id={id} className="blog-h2">
<HeadingAnchor id={id} />
{children}
</h2>
);
},
h3({ children }) {
const id = generateSlug(getTextContent(children));
return (
<h3 id={id} className="blog-h3">
<HeadingAnchor id={id} />
{children}
</h3>
);
},
h4({ children }) {
const id = generateSlug(getTextContent(children));
return (
<h4 id={id} className="blog-h4">
<HeadingAnchor id={id} />
{children}
</h4>
);
},
h5({ children }) {
const id = generateSlug(getTextContent(children));
return (
<h5 id={id} className="blog-h5">
<HeadingAnchor id={id} />
{children}
</h5>
);
},
h6({ children }) {
const id = generateSlug(getTextContent(children));
return (
<h6 id={id} className="blog-h6">
<HeadingAnchor id={id} />
{children}
</h6>
);
},
ul({ children }) {
return <ul className="blog-ul">{children}</ul>;
},
ol({ children }) {
return <ol className="blog-ol">{children}</ol>;
},
li({ children }) {
return <li className="blog-li">{children}</li>;
},
hr() {
return <hr className="blog-hr" />;
},
// Table components for GitHub-style tables
table({ children }) {
return (
<div className="blog-table-wrapper">
<table className="blog-table">{children}</table>
</div>
);
},
thead({ children }) {
return <thead className="blog-thead">{children}</thead>;
},
tbody({ children }) {
return <tbody className="blog-tbody">{children}</tbody>;
},
tr({ children }) {
return <tr className="blog-tr">{children}</tr>;
},
th({ children }) {
return <th className="blog-th">{children}</th>;
},
td({ children }) {
return <td className="blog-td">{children}</td>;
},
// Iframe component with domain whitelisting for YouTube and Twitter/X
iframe(props) {
const src = props.src as string;
if (!src) return null;
try { const codeString = String(children).replace(/\n$/, "");
const url = new URL(src); const language = match ? match[1] : "text";
const isAllowed = ALLOWED_IFRAME_DOMAINS.some( const isTextBlock = language === "text";
(domain) =>
url.hostname === domain || url.hostname.endsWith("." + domain) // Custom styles for text blocks to enable wrapping
); const textBlockStyle = isTextBlock
if (!isAllowed) return null; ? {
whiteSpace: "pre-wrap" as const,
wordWrap: "break-word" as const,
overflowWrap: "break-word" as const,
}
: {};
return ( return (
<div className="embed-container"> <div
<iframe className={`code-block-wrapper ${isTextBlock ? "code-block-text" : ""}`}
{...props} >
sandbox="allow-scripts allow-same-origin allow-popups" {match && <span className="code-language">{match[1]}</span>}
loading="lazy" <CodeCopyButton code={codeString} />
/> <SyntaxHighlighter
style={getCodeTheme()}
language={language}
PreTag="div"
customStyle={textBlockStyle}
codeTagProps={
isTextBlock ? { style: textBlockStyle } : undefined
}
>
{codeString}
</SyntaxHighlighter>
</div> </div>
); );
} catch { },
return null; img({ src, alt }) {
} const handleImageClick = () => {
}, if (isLightboxEnabled && src) {
setLightboxImage({ src, alt: alt || "" });
}
};
return (
<span className="blog-image-wrapper">
<img
src={src}
alt={alt || ""}
className={`blog-image ${isLightboxEnabled ? "blog-image-clickable" : ""}`}
loading="lazy"
onClick={isLightboxEnabled ? handleImageClick : undefined}
style={
isLightboxEnabled ? { cursor: "pointer" } : undefined
}
/>
{alt && <span className="blog-image-caption">{alt}</span>}
</span>
);
},
a({ href, children }) {
const isExternal = href?.startsWith("http");
return (
<a
href={href}
target={isExternal ? "_blank" : undefined}
rel={isExternal ? "noopener noreferrer" : undefined}
className="blog-link"
>
{children}
</a>
);
},
blockquote({ children }) {
return (
<blockquote className="blog-blockquote">{children}</blockquote>
);
},
h1({ children }) {
const id = generateSlug(getTextContent(children));
return (
<h1 id={id} className="blog-h1">
<HeadingAnchor id={id} />
{children}
</h1>
);
},
h2({ children }) {
const id = generateSlug(getTextContent(children));
return (
<h2 id={id} className="blog-h2">
<HeadingAnchor id={id} />
{children}
</h2>
);
},
h3({ children }) {
const id = generateSlug(getTextContent(children));
return (
<h3 id={id} className="blog-h3">
<HeadingAnchor id={id} />
{children}
</h3>
);
},
h4({ children }) {
const id = generateSlug(getTextContent(children));
return (
<h4 id={id} className="blog-h4">
<HeadingAnchor id={id} />
{children}
</h4>
);
},
h5({ children }) {
const id = generateSlug(getTextContent(children));
return (
<h5 id={id} className="blog-h5">
<HeadingAnchor id={id} />
{children}
</h5>
);
},
h6({ children }) {
const id = generateSlug(getTextContent(children));
return (
<h6 id={id} className="blog-h6">
<HeadingAnchor id={id} />
{children}
</h6>
);
},
ul({ children }) {
return <ul className="blog-ul">{children}</ul>;
},
ol({ children }) {
return <ol className="blog-ol">{children}</ol>;
},
li({ children }) {
return <li className="blog-li">{children}</li>;
},
hr() {
return <hr className="blog-hr" />;
},
// Table components for GitHub-style tables
table({ children }) {
return (
<div className="blog-table-wrapper">
<table className="blog-table">{children}</table>
</div>
);
},
thead({ children }) {
return <thead className="blog-thead">{children}</thead>;
},
tbody({ children }) {
return <tbody className="blog-tbody">{children}</tbody>;
},
tr({ children }) {
return <tr className="blog-tr">{children}</tr>;
},
th({ children }) {
return <th className="blog-th">{children}</th>;
},
td({ children }) {
return <td className="blog-td">{children}</td>;
},
// Iframe component with domain whitelisting for YouTube and Twitter/X
iframe(props) {
const src = props.src as string;
if (!src) return null;
try {
const url = new URL(src);
const isAllowed = ALLOWED_IFRAME_DOMAINS.some(
(domain) =>
url.hostname === domain ||
url.hostname.endsWith("." + domain),
);
if (!isAllowed) return null;
return (
<div className="embed-container">
<iframe
{...props}
sandbox="allow-scripts allow-same-origin allow-popups"
loading="lazy"
/>
</div>
);
} catch {
return null;
}
},
}} }}
> >
{cleanedContent} {cleanedContent}

View File

@@ -0,0 +1,101 @@
import { ReactNode, useState, useEffect } from "react";
import DocsSidebar from "./DocsSidebar";
import DocsTOC from "./DocsTOC";
import AIChatView from "./AIChatView";
import type { Heading } from "../utils/extractHeadings";
import siteConfig from "../config/siteConfig";
import { ChevronDown, ChevronUp } from "lucide-react";
// Storage key for AI chat expanded state
const AI_CHAT_EXPANDED_KEY = "docs-ai-chat-expanded";
interface DocsLayoutProps {
children: ReactNode;
headings: Heading[];
currentSlug: string;
aiChatEnabled?: boolean; // From frontmatter aiChat: true/false
pageContent?: string; // Page/post content for AI context
}
export default function DocsLayout({
children,
headings,
currentSlug,
aiChatEnabled = false,
pageContent,
}: DocsLayoutProps) {
const hasTOC = headings.length > 0;
// Check if AI chat should be shown (requires global config + frontmatter)
const showAIChat =
siteConfig.aiChat?.enabledOnContent && aiChatEnabled === true && currentSlug;
// AI chat expanded state (closed by default)
const [aiChatExpanded, setAiChatExpanded] = useState(() => {
try {
const stored = localStorage.getItem(AI_CHAT_EXPANDED_KEY);
return stored === "true";
} catch {
return false;
}
});
// Persist AI chat expanded state
useEffect(() => {
try {
localStorage.setItem(AI_CHAT_EXPANDED_KEY, aiChatExpanded.toString());
} catch {
// Ignore storage errors
}
}, [aiChatExpanded]);
// Show right sidebar if TOC exists OR AI chat is enabled
const hasRightSidebar = hasTOC || showAIChat;
return (
<div className={`docs-layout ${!hasRightSidebar ? "no-toc" : ""}`}>
{/* Left sidebar - docs navigation */}
<aside className="docs-sidebar-left">
<DocsSidebar currentSlug={currentSlug} />
</aside>
{/* Main content */}
<main className="docs-content">{children}</main>
{/* Right sidebar - AI chat toggle + table of contents */}
{hasRightSidebar && (
<aside className="docs-sidebar-right">
{/* AI Chat toggle section (above TOC) */}
{showAIChat && (
<div className="docs-ai-chat-section">
<button
className="docs-ai-chat-toggle"
onClick={() => setAiChatExpanded(!aiChatExpanded)}
type="button"
aria-expanded={aiChatExpanded}
>
<span className="docs-ai-chat-toggle-text">AI Agent</span>
{aiChatExpanded ? (
<ChevronUp size={16} />
) : (
<ChevronDown size={16} />
)}
</button>
{aiChatExpanded && (
<div className="docs-ai-chat-container">
<AIChatView
contextId={currentSlug}
pageContent={pageContent}
hideAttachments={true}
/>
</div>
)}
</div>
)}
{/* TOC section */}
{hasTOC && <DocsTOC headings={headings} />}
</aside>
)}
</div>
);
}

View File

@@ -0,0 +1,213 @@
import { useQuery } from "convex/react";
import { api } from "../../convex/_generated/api";
import { Link, useLocation } from "react-router-dom";
import { ChevronRight } from "lucide-react";
import { useState, useEffect, useMemo } from "react";
import siteConfig from "../config/siteConfig";
// Docs item from query
interface DocsItem {
_id: string;
slug: string;
title: string;
docsSectionGroup?: string;
docsSectionOrder?: number;
docsSectionGroupOrder?: number;
}
// Grouped docs structure
interface DocsGroup {
name: string;
items: DocsItem[];
}
interface DocsSidebarProps {
currentSlug?: string;
isMobile?: boolean;
}
// Storage key for expanded state
const STORAGE_KEY = "docs-sidebar-expanded-state";
export default function DocsSidebar({ currentSlug, isMobile }: DocsSidebarProps) {
const location = useLocation();
const docsPosts = useQuery(api.posts.getDocsPosts);
const docsPages = useQuery(api.pages.getDocsPages);
// Combine posts and pages
const allDocsItems = useMemo(() => {
const items: DocsItem[] = [];
if (docsPosts) {
items.push(...docsPosts.map((p) => ({ ...p, _id: p._id.toString() })));
}
if (docsPages) {
items.push(...docsPages.map((p) => ({ ...p, _id: p._id.toString() })));
}
return items;
}, [docsPosts, docsPages]);
// Group items by docsSectionGroup
const groups = useMemo(() => {
const groupMap = new Map<string, DocsItem[]>();
const ungrouped: DocsItem[] = [];
for (const item of allDocsItems) {
const groupName = item.docsSectionGroup || "";
if (groupName) {
if (!groupMap.has(groupName)) {
groupMap.set(groupName, []);
}
groupMap.get(groupName)!.push(item);
} else {
ungrouped.push(item);
}
}
// Sort items within each group by docsSectionOrder
const sortItems = (a: DocsItem, b: DocsItem) => {
const orderA = a.docsSectionOrder ?? 999;
const orderB = b.docsSectionOrder ?? 999;
if (orderA !== orderB) return orderA - orderB;
return a.title.localeCompare(b.title);
};
// Convert to array and sort
const result: DocsGroup[] = [];
// Add groups sorted by docsSectionGroupOrder (using minimum order from items in each group)
const sortedGroupNames = Array.from(groupMap.keys()).sort((a, b) => {
const groupAItems = groupMap.get(a)!;
const groupBItems = groupMap.get(b)!;
const orderA = Math.min(...groupAItems.map(i => i.docsSectionGroupOrder ?? 999));
const orderB = Math.min(...groupBItems.map(i => i.docsSectionGroupOrder ?? 999));
if (orderA !== orderB) return orderA - orderB;
return a.localeCompare(b); // Fallback to alphabetical
});
for (const name of sortedGroupNames) {
const items = groupMap.get(name)!;
items.sort(sortItems);
result.push({ name, items });
}
// Add ungrouped items at the end if any
if (ungrouped.length > 0) {
ungrouped.sort(sortItems);
result.push({ name: "", items: ungrouped });
}
return result;
}, [allDocsItems]);
// Expanded state for groups
const [expanded, setExpanded] = useState<Set<string>>(() => {
// Load from localStorage or default to all expanded
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
const parsed = JSON.parse(stored);
if (Array.isArray(parsed)) {
return new Set(parsed);
}
}
} catch {
// Ignore parsing errors
}
// Default: expand all groups if siteConfig says so
if (siteConfig.docsSection?.defaultExpanded) {
return new Set(groups.map((g) => g.name));
}
return new Set<string>();
});
// Persist expanded state to localStorage
useEffect(() => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(Array.from(expanded)));
} catch {
// Ignore storage errors
}
}, [expanded]);
// Update expanded state when groups change (ensure new groups are expanded if defaultExpanded)
useEffect(() => {
if (siteConfig.docsSection?.defaultExpanded && groups.length > 0) {
setExpanded((prev) => {
const newExpanded = new Set(prev);
for (const group of groups) {
if (group.name && !prev.has(group.name)) {
newExpanded.add(group.name);
}
}
return newExpanded;
});
}
}, [groups]);
// Get current slug from URL if not provided
const activeSlug = currentSlug || location.pathname.replace(/^\//, "");
// Toggle group expansion
const toggleGroup = (name: string) => {
setExpanded((prev) => {
const newExpanded = new Set(prev);
if (newExpanded.has(name)) {
newExpanded.delete(name);
} else {
newExpanded.add(name);
}
return newExpanded;
});
};
// Loading state
if (docsPosts === undefined || docsPages === undefined) {
return null;
}
// No docs items
if (allDocsItems.length === 0) {
return null;
}
const containerClass = isMobile ? "docs-mobile-sidebar" : "docs-sidebar-nav";
return (
<nav className={containerClass}>
<h3 className="docs-sidebar-title">
{siteConfig.docsSection?.title || "Documentation"}
</h3>
{groups.map((group) => (
<div key={group.name || "ungrouped"} className="docs-sidebar-group">
{/* Group title (only for named groups) */}
{group.name && (
<button
className={`docs-sidebar-group-title ${expanded.has(group.name) ? "expanded" : ""}`}
onClick={() => toggleGroup(group.name)}
type="button"
>
<ChevronRight />
<span>{group.name}</span>
</button>
)}
{/* Group items (show if no name or if expanded) */}
{(!group.name || expanded.has(group.name)) && (
<ul className="docs-sidebar-group-list">
{group.items.map((item) => (
<li key={item._id} className="docs-sidebar-item">
<Link
to={`/${item.slug}`}
className={`docs-sidebar-link ${activeSlug === item.slug ? "active" : ""}`}
>
{item.title}
</Link>
</li>
))}
</ul>
)}
</div>
))}
</nav>
);
}

129
src/components/DocsTOC.tsx Normal file
View File

@@ -0,0 +1,129 @@
import { useState, useEffect, useRef, useCallback } from "react";
import type { Heading } from "../utils/extractHeadings";
interface DocsTOCProps {
headings: Heading[];
}
// Get absolute position of element from top of document
function getElementTop(element: HTMLElement): number {
const rect = element.getBoundingClientRect();
return rect.top + window.scrollY;
}
export default function DocsTOC({ headings }: DocsTOCProps) {
const [activeId, setActiveId] = useState<string>("");
const isNavigatingRef = useRef(false);
// Scroll tracking to highlight active heading
useEffect(() => {
if (headings.length === 0) return;
const handleScroll = () => {
// Skip during programmatic navigation
if (isNavigatingRef.current) return;
const scrollPosition = window.scrollY + 120; // Header offset
// Find the heading that's currently in view
let currentId = "";
for (const heading of headings) {
const element = document.getElementById(heading.id);
if (element) {
const top = getElementTop(element);
if (scrollPosition >= top) {
currentId = heading.id;
} else {
break;
}
}
}
setActiveId(currentId);
};
// Initial check
handleScroll();
window.addEventListener("scroll", handleScroll, { passive: true });
return () => window.removeEventListener("scroll", handleScroll);
}, [headings]);
// Navigate to heading
const navigateToHeading = useCallback((id: string) => {
const element = document.getElementById(id);
if (!element) return;
isNavigatingRef.current = true;
setActiveId(id);
// Scroll with header offset
const headerOffset = 80;
const elementTop = getElementTop(element);
const targetPosition = elementTop - headerOffset;
window.scrollTo({
top: Math.max(0, targetPosition),
behavior: "smooth",
});
// Update URL hash
window.history.pushState(null, "", `#${id}`);
// Re-enable scroll tracking after animation
setTimeout(() => {
isNavigatingRef.current = false;
}, 500);
}, []);
// Handle hash changes (browser back/forward)
useEffect(() => {
const handleHashChange = () => {
const hash = window.location.hash.slice(1);
if (hash && headings.some((h) => h.id === hash)) {
navigateToHeading(hash);
}
};
window.addEventListener("hashchange", handleHashChange);
return () => window.removeEventListener("hashchange", handleHashChange);
}, [headings, navigateToHeading]);
// Initial hash navigation on mount
useEffect(() => {
const hash = window.location.hash.slice(1);
if (hash && headings.some((h) => h.id === hash)) {
// Delay to ensure DOM is ready
requestAnimationFrame(() => {
navigateToHeading(hash);
});
}
}, [headings, navigateToHeading]);
// No headings, don't render
if (headings.length === 0) {
return null;
}
return (
<nav className="docs-toc">
<h3 className="docs-toc-title">On this page</h3>
<ul className="docs-toc-list">
{headings.map((heading) => (
<li key={heading.id} className="docs-toc-item">
<a
href={`#${heading.id}`}
className={`docs-toc-link level-${heading.level} ${activeId === heading.id ? "active" : ""}`}
onClick={(e) => {
e.preventDefault();
navigateToHeading(heading.id);
}}
>
{heading.text}
</a>
</li>
))}
</ul>
</nav>
);
}

View File

@@ -31,6 +31,23 @@ export default function Layout({ children }: LayoutProps) {
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const location = useLocation(); const location = useLocation();
// Fetch docs pages and posts for detecting if current page is in docs section
const docsPages = useQuery(
siteConfig.docsSection?.enabled ? api.pages.getDocsPages : "skip"
);
const docsPosts = useQuery(
siteConfig.docsSection?.enabled ? api.posts.getDocsPosts : "skip"
);
// Check if current page is a docs page
const currentSlug = location.pathname.replace(/^\//, "");
const docsSlug = siteConfig.docsSection?.slug || "docs";
const isDocsLanding = currentSlug === docsSlug;
const isDocsPage =
isDocsLanding ||
(docsPages?.some((p) => p.slug === currentSlug) ?? false) ||
(docsPosts?.some((p) => p.slug === currentSlug) ?? false);
// Get sidebar headings from context (if available) // Get sidebar headings from context (if available)
const sidebarContext = useSidebarOptional(); const sidebarContext = useSidebarOptional();
const sidebarHeadings = sidebarContext?.headings || []; const sidebarHeadings = sidebarContext?.headings || [];
@@ -103,6 +120,15 @@ export default function Layout({ children }: LayoutProps) {
}); });
} }
// Add Docs link if enabled
if (siteConfig.docsSection?.enabled && siteConfig.docsSection?.showInNav) {
navItems.push({
slug: siteConfig.docsSection.slug,
title: siteConfig.docsSection.title,
order: siteConfig.docsSection.order ?? 1,
});
}
// Add hardcoded nav items (React routes like /stats, /write) // Add hardcoded nav items (React routes like /stats, /write)
if (siteConfig.hardcodedNavItems && siteConfig.hardcodedNavItems.length > 0) { if (siteConfig.hardcodedNavItems && siteConfig.hardcodedNavItems.length > 0) {
siteConfig.hardcodedNavItems.forEach((item) => { siteConfig.hardcodedNavItems.forEach((item) => {
@@ -236,6 +262,8 @@ export default function Layout({ children }: LayoutProps) {
onClose={closeMobileMenu} onClose={closeMobileMenu}
sidebarHeadings={sidebarHeadings} sidebarHeadings={sidebarHeadings}
sidebarActiveId={sidebarActiveId} sidebarActiveId={sidebarActiveId}
showDocsNav={isDocsPage}
currentDocsSlug={currentSlug}
> >
{/* Page navigation links in mobile menu (same order as desktop) */} {/* Page navigation links in mobile menu (same order as desktop) */}
<nav className="mobile-nav-links"> <nav className="mobile-nav-links">

View File

@@ -2,6 +2,8 @@ import { ReactNode, useEffect, useRef, useCallback } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { ChevronRight } from "lucide-react"; import { ChevronRight } from "lucide-react";
import { Heading } from "../utils/extractHeadings"; import { Heading } from "../utils/extractHeadings";
import DocsSidebar from "./DocsSidebar";
import siteConfig from "../config/siteConfig";
interface MobileMenuProps { interface MobileMenuProps {
isOpen: boolean; isOpen: boolean;
@@ -9,6 +11,8 @@ interface MobileMenuProps {
children: ReactNode; children: ReactNode;
sidebarHeadings?: Heading[]; sidebarHeadings?: Heading[];
sidebarActiveId?: string; sidebarActiveId?: string;
showDocsNav?: boolean;
currentDocsSlug?: string;
} }
/** /**
@@ -22,9 +26,12 @@ export default function MobileMenu({
children, children,
sidebarHeadings = [], sidebarHeadings = [],
sidebarActiveId, sidebarActiveId,
showDocsNav = false,
currentDocsSlug,
}: MobileMenuProps) { }: MobileMenuProps) {
const menuRef = useRef<HTMLDivElement>(null); const menuRef = useRef<HTMLDivElement>(null);
const hasSidebar = sidebarHeadings.length > 0; const hasSidebar = sidebarHeadings.length > 0;
const showDocsSection = showDocsNav && siteConfig.docsSection?.enabled;
// Handle escape key to close menu // Handle escape key to close menu
useEffect(() => { useEffect(() => {
@@ -136,6 +143,13 @@ export default function MobileMenu({
<div className="mobile-menu-content"> <div className="mobile-menu-content">
{children} {children}
{/* Docs sidebar navigation (when on a docs page) */}
{showDocsSection && (
<div className="mobile-menu-docs">
<DocsSidebar currentSlug={currentDocsSlug} isMobile={true} />
</div>
)}
{/* Table of contents from sidebar (if page has sidebar) */} {/* Table of contents from sidebar (if page has sidebar) */}
{hasSidebar && ( {hasSidebar && (
<div className="mobile-menu-toc"> <div className="mobile-menu-toc">

View File

@@ -177,6 +177,18 @@ export interface StatsPageConfig {
showInNav: boolean; // Show link in navigation (controlled via hardcodedNavItems) showInNav: boolean; // Show link in navigation (controlled via hardcodedNavItems)
} }
// Docs section configuration
// Creates a Starlight-style documentation layout with left sidebar and right TOC
// Pages/posts with docsSection: true in frontmatter appear in docs navigation
export interface DocsSectionConfig {
enabled: boolean; // Global toggle for docs section
slug: string; // Base URL path (e.g., "docs" for /docs)
title: string; // Page title for docs landing
showInNav: boolean; // Show "Docs" link in navigation
order?: number; // Nav order (lower = first)
defaultExpanded: boolean; // Expand all sidebar groups by default
}
// Newsletter notifications configuration // Newsletter notifications configuration
// Sends developer notifications for subscriber events // Sends developer notifications for subscriber events
// Uses AGENTMAIL_CONTACT_EMAIL or AGENTMAIL_INBOX as recipient // Uses AGENTMAIL_CONTACT_EMAIL or AGENTMAIL_INBOX as recipient
@@ -325,6 +337,9 @@ export interface SiteConfig {
// Stats page configuration (optional) // Stats page configuration (optional)
statsPage?: StatsPageConfig; statsPage?: StatsPageConfig;
// Docs section configuration (optional)
docsSection?: DocsSectionConfig;
// Newsletter notifications configuration (optional) // Newsletter notifications configuration (optional)
newsletterNotifications?: NewsletterNotificationsConfig; newsletterNotifications?: NewsletterNotificationsConfig;
@@ -620,6 +635,19 @@ export const siteConfig: SiteConfig = {
showInNav: true, // Show link in navigation (also controlled via hardcodedNavItems) showInNav: true, // Show link in navigation (also controlled via hardcodedNavItems)
}, },
// Docs section configuration
// Creates a Starlight-style documentation layout with left sidebar navigation and right TOC
// Add docsSection: true to page/post frontmatter to include in docs navigation
// Set docsLanding: true on one page to make it the /docs landing page
docsSection: {
enabled: true, // Global toggle for docs section
slug: "docs", // Base URL: /docs
title: "Docs", // Page title
showInNav: true, // Show "Docs" link in navigation
order: 1, // Nav order (lower = first)
defaultExpanded: true, // Expand all sidebar groups by default
},
// Newsletter notifications configuration // Newsletter notifications configuration
// Sends developer notifications for subscriber events via AgentMail // Sends developer notifications for subscriber events via AgentMail
newsletterNotifications: { newsletterNotifications: {

139
src/pages/DocsPage.tsx Normal file
View File

@@ -0,0 +1,139 @@
import { useEffect } from "react";
import { Link } from "react-router-dom";
import { useQuery } from "convex/react";
import { api } from "../../convex/_generated/api";
import DocsLayout from "../components/DocsLayout";
import BlogPost from "../components/BlogPost";
import { extractHeadings } from "../utils/extractHeadings";
import siteConfig from "../config/siteConfig";
import { ArrowRight } from "lucide-react";
export default function DocsPage() {
// Fetch landing page content (checks pages first, then posts)
const landingPage = useQuery(api.pages.getDocsLandingPage);
const landingPost = useQuery(api.posts.getDocsLandingPost);
// Fetch all docs items for fallback (first doc if no landing)
const docsPosts = useQuery(api.posts.getDocsPosts);
const docsPages = useQuery(api.pages.getDocsPages);
// Determine which content to use: page takes priority over post
const landingContent = landingPage || landingPost;
// Get first doc item as fallback if no landing page is set
const allDocsItems = [
...(docsPages || []),
...(docsPosts || []),
].sort((a, b) => {
const orderA = a.docsSectionOrder ?? 999;
const orderB = b.docsSectionOrder ?? 999;
return orderA - orderB;
});
const firstDocSlug = allDocsItems.length > 0 ? allDocsItems[0].slug : null;
// Update page title
useEffect(() => {
const title = landingContent?.title || siteConfig.docsSection?.title || "Documentation";
document.title = `${title} | ${siteConfig.name}`;
return () => {
document.title = siteConfig.name;
};
}, [landingContent]);
// Loading state - show skeleton to prevent flash
if (
landingPage === undefined ||
landingPost === undefined ||
docsPosts === undefined ||
docsPages === undefined
) {
return (
<DocsLayout headings={[]} currentSlug="">
<article className="docs-article">
<div className="docs-article-loading">
<div className="docs-loading-skeleton docs-loading-title" />
<div className="docs-loading-skeleton docs-loading-text" />
<div className="docs-loading-skeleton docs-loading-text" />
<div className="docs-loading-skeleton docs-loading-text-short" />
</div>
</article>
</DocsLayout>
);
}
// If we have landing content, render it with DocsLayout
if (landingContent) {
const headings = extractHeadings(landingContent.content);
return (
<DocsLayout headings={headings} currentSlug={landingContent.slug}>
<article className="docs-article">
<header className="docs-article-header">
<h1 className="docs-article-title">{landingContent.title}</h1>
{"description" in landingContent && landingContent.description && (
<p className="docs-article-description">
{landingContent.description}
</p>
)}
{"excerpt" in landingContent && landingContent.excerpt && (
<p className="docs-article-description">{landingContent.excerpt}</p>
)}
</header>
<BlogPost
content={landingContent.content}
slug={landingContent.slug}
pageType={"date" in landingContent ? "post" : "page"}
/>
</article>
</DocsLayout>
);
}
// No landing page set - show a getting started guide
return (
<DocsLayout headings={[]} currentSlug="">
<article className="docs-article">
<header className="docs-article-header">
<h1 className="docs-article-title">
{siteConfig.docsSection?.title || "Documentation"}
</h1>
<p className="docs-article-description">
Welcome to the documentation section.
</p>
</header>
<div className="docs-landing-content">
{allDocsItems.length > 0 ? (
<>
<p>Browse the documentation using the sidebar navigation, or get started with one of these pages:</p>
<ul className="docs-landing-list">
{allDocsItems.slice(0, 5).map((item) => (
<li key={item.slug} className="docs-landing-item">
<Link to={`/${item.slug}`} className="docs-landing-link">
<span>{item.title}</span>
<ArrowRight size={16} />
</Link>
</li>
))}
</ul>
{allDocsItems.length > 5 && (
<p className="docs-landing-more">
And {allDocsItems.length - 5} more pages in the sidebar...
</p>
)}
</>
) : (
<div className="docs-landing-empty">
<p>No documentation pages have been created yet.</p>
<p>
To add a page to the docs section, add{" "}
<code>docsSection: true</code> to the frontmatter of any
markdown file.
</p>
</div>
)}
</div>
</article>
</DocsLayout>
);
}

View File

@@ -5,6 +5,7 @@ import BlogPost from "../components/BlogPost";
import CopyPageDropdown from "../components/CopyPageDropdown"; import CopyPageDropdown from "../components/CopyPageDropdown";
import PageSidebar from "../components/PageSidebar"; import PageSidebar from "../components/PageSidebar";
import RightSidebar from "../components/RightSidebar"; import RightSidebar from "../components/RightSidebar";
import DocsLayout from "../components/DocsLayout";
import Footer from "../components/Footer"; import Footer from "../components/Footer";
import SocialFooter from "../components/SocialFooter"; import SocialFooter from "../components/SocialFooter";
import NewsletterSignup from "../components/NewsletterSignup"; import NewsletterSignup from "../components/NewsletterSignup";
@@ -196,13 +197,79 @@ export default function Post({
}; };
}, [post, page]); }, [post, page]);
// Check if we're loading a docs page - keep layout mounted to prevent flash
const isDocsRoute = siteConfig.docsSection?.enabled && slug;
// Return null during initial load to avoid flash (Convex data arrives quickly) // Return null during initial load to avoid flash (Convex data arrives quickly)
// But for docs pages, show skeleton within DocsLayout to prevent sidebar flash
if (page === undefined || post === undefined) { if (page === undefined || post === undefined) {
if (isDocsRoute) {
// Keep DocsLayout mounted during loading to prevent sidebar flash
return (
<DocsLayout headings={[]} currentSlug={slug || ""}>
<article className="docs-article">
<div className="docs-article-loading">
<div className="docs-loading-skeleton docs-loading-title" />
<div className="docs-loading-skeleton docs-loading-text" />
<div className="docs-loading-skeleton docs-loading-text" />
<div className="docs-loading-skeleton docs-loading-text-short" />
</div>
</article>
</DocsLayout>
);
}
return null; return null;
} }
// If it's a static page, render simplified view // If it's a static page, render simplified view
if (page) { if (page) {
// Check if this page should use docs layout
if (page.docsSection && siteConfig.docsSection?.enabled) {
const docsHeadings = extractHeadings(page.content);
return (
<DocsLayout
headings={docsHeadings}
currentSlug={page.slug}
aiChatEnabled={page.aiChat}
pageContent={page.content}
>
<article className="docs-article">
<div className="docs-article-actions">
<CopyPageDropdown
title={page.title}
content={page.content}
url={`${SITE_URL}/${page.slug}`}
slug={page.slug}
description={page.excerpt}
/>
</div>
{page.showImageAtTop && page.image && (
<div className="post-header-image">
<img
src={page.image}
alt={page.title}
className="post-header-image-img"
/>
</div>
)}
<header className="docs-article-header">
<h1 className="docs-article-title">{page.title}</h1>
{page.excerpt && (
<p className="docs-article-description">{page.excerpt}</p>
)}
</header>
<BlogPost content={page.content} slug={page.slug} pageType="page" />
{siteConfig.footer.enabled &&
(page.showFooter !== undefined
? page.showFooter
: siteConfig.footer.showOnPages) && (
<Footer content={page.footer} />
)}
</article>
</DocsLayout>
);
}
// Extract headings for sidebar TOC (only for pages with layout: "sidebar") // Extract headings for sidebar TOC (only for pages with layout: "sidebar")
const headings = const headings =
page.layout === "sidebar" ? extractHeadings(page.content) : []; page.layout === "sidebar" ? extractHeadings(page.content) : [];
@@ -385,6 +452,55 @@ export default function Post({
); );
}; };
// Check if this post should use docs layout
if (post.docsSection && siteConfig.docsSection?.enabled) {
const docsHeadings = extractHeadings(post.content);
return (
<DocsLayout
headings={docsHeadings}
currentSlug={post.slug}
aiChatEnabled={post.aiChat}
pageContent={post.content}
>
<article className="docs-article">
<div className="docs-article-actions">
<CopyPageDropdown
title={post.title}
content={post.content}
url={`${SITE_URL}/${post.slug}`}
slug={post.slug}
description={post.description}
date={post.date}
tags={post.tags}
/>
</div>
{post.showImageAtTop && post.image && (
<div className="post-header-image">
<img
src={post.image}
alt={post.title}
className="post-header-image-img"
/>
</div>
)}
<header className="docs-article-header">
<h1 className="docs-article-title">{post.title}</h1>
{post.description && (
<p className="docs-article-description">{post.description}</p>
)}
</header>
<BlogPost content={post.content} slug={post.slug} pageType="post" />
{siteConfig.footer.enabled &&
(post.showFooter !== undefined
? post.showFooter
: siteConfig.footer.showOnPosts) && (
<Footer content={post.footer} />
)}
</article>
</DocsLayout>
);
}
// Extract headings for sidebar TOC (only for posts with layout: "sidebar") // Extract headings for sidebar TOC (only for posts with layout: "sidebar")
const headings = const headings =
post?.layout === "sidebar" ? extractHeadings(post.content) : []; post?.layout === "sidebar" ? extractHeadings(post.content) : [];

View File

@@ -4827,6 +4827,37 @@ body {
background-color: var(--bg-hover); background-color: var(--bg-hover);
} }
/* Mobile docs sidebar in hamburger menu */
.mobile-menu-docs {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid var(--border-color);
}
.mobile-menu-docs .docs-mobile-sidebar {
padding: 0;
}
.mobile-menu-docs .docs-sidebar-title {
font-size: var(--font-size-mobile-toc-title);
padding: 4px 12px 8px;
margin-bottom: 0;
}
.mobile-menu-docs .docs-sidebar-group {
margin-bottom: 8px;
}
.mobile-menu-docs .docs-sidebar-group-title {
padding: 6px 12px;
font-size: var(--font-size-sm);
}
.mobile-menu-docs .docs-sidebar-link {
padding: 6px 12px;
font-size: var(--font-size-mobile-toc-link);
}
/* Mobile menu table of contents */ /* Mobile menu table of contents */
.mobile-menu-toc { .mobile-menu-toc {
margin-top: 16px; margin-top: 16px;
@@ -11937,3 +11968,589 @@ body {
font-size: var(--font-size-xs); font-size: var(--font-size-xs);
} }
} }
/* ============================================
Docs Section Layout (Starlight-style)
============================================ */
/* Three-column docs layout - full width */
/* Sidebars are position: fixed, so no grid needed */
.docs-layout {
width: 100%;
min-height: calc(100vh - 60px);
margin: 0;
padding: 0;
}
/* Docs layout without TOC (two-column) */
.docs-layout.no-toc {
/* Same as default - sidebars are fixed */
}
/* Left sidebar for docs navigation - flush left */
.docs-sidebar-left {
position: fixed;
top: 80px;
left: 0;
width: 280px;
height: calc(100vh - 80px);
overflow-y: auto;
background-color: var(--bg-sidebar);
padding: 24px;
border-right: 1px solid var(--border-sidebar);
border-radius: 6px;
border-top: 1px solid var(--border-sidebar);
z-index: 10;
}
.docs-sidebar-left::-webkit-scrollbar {
width: 6px;
}
.docs-sidebar-left::-webkit-scrollbar-track {
background: transparent;
}
.docs-sidebar-left::-webkit-scrollbar-thumb {
background-color: var(--border-color);
border-radius: 3px;
}
.docs-sidebar-left::-webkit-scrollbar-thumb:hover {
background-color: var(--text-muted);
}
/* Right sidebar for table of contents - flush right */
.docs-sidebar-right {
position: fixed;
top: 80px;
right: 0;
width: 280px;
height: calc(100vh - 80px);
overflow-y: auto;
background-color: var(--bg-sidebar);
padding: 24px;
border-left: 1px solid var(--border-sidebar);
border-radius: 6px;
border-top: 1px solid var(--border-sidebar);
z-index: 10;
}
.docs-sidebar-right::-webkit-scrollbar {
width: 6px;
}
.docs-sidebar-right::-webkit-scrollbar-track {
background: transparent;
}
.docs-sidebar-right::-webkit-scrollbar-thumb {
background-color: var(--border-color);
border-radius: 3px;
}
.docs-sidebar-right::-webkit-scrollbar-thumb:hover {
background-color: var(--text-muted);
}
/* AI Chat section in docs right sidebar */
.docs-ai-chat-section {
padding: 0px;
padding-bottom: 12px;
border-bottom: 1px solid var(--border-sidebar);
}
.docs-ai-chat-toggle {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 10px 14px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
cursor: pointer;
font-size: var(--font-size-sm);
font-weight: 500;
color: var(--text-primary);
transition: all 0.15s ease;
}
.docs-ai-chat-toggle:hover {
background: var(--bg-tertiary);
border-color: var(--text-muted);
}
.docs-ai-chat-toggle[aria-expanded="true"] {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
border-bottom-color: transparent;
}
.docs-ai-chat-toggle-text {
display: flex;
align-items: center;
gap: 8px;
}
.docs-ai-chat-container {
border: 1px solid var(--border-color);
border-top: none;
border-radius: 0 0 8px 8px;
background: var(--bg-primary);
max-height: 400px;
overflow: hidden;
display: flex;
flex-direction: column;
}
/* Override AI chat view styles for docs sidebar */
.docs-ai-chat-container .ai-chat-view {
height: 100%;
max-height: 400px;
border: none;
border-radius: 0;
}
.docs-ai-chat-container .ai-chat-messages {
max-height: 280px;
min-height: 150px;
}
.docs-ai-chat-container .ai-chat-input-wrapper {
padding: 12px;
border-top: 1px solid var(--border-color);
}
.docs-ai-chat-container .ai-chat-input {
font-size: var(--font-size-sm);
min-height: 36px;
max-height: 80px;
}
/* Main content area - uses margins for fixed sidebars */
.docs-content {
margin-left: auto;
margin-right: auto;
padding: 32px 48px;
overflow-y: auto;
min-height: calc(100vh - 80px);
}
/* Center the article within docs-content */
.docs-content .docs-article {
max-width: 800px;
margin-left: auto;
margin-right: auto;
}
/* No TOC layout - content takes more space */
.docs-layout.no-toc .docs-content {
margin-right: 0;
}
/* Docs sidebar navigation */
.docs-sidebar-nav {
padding-right: 8px;
}
.docs-sidebar-title {
font-size: var(--font-size-xs);
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
margin: 0 0 16px;
padding: 0 8px 12px;
border-bottom: 1px solid var(--border-sidebar);
}
/* Docs sidebar groups */
.docs-sidebar-group {
margin-bottom: 16px;
padding-bottom: 16px;
border-bottom: 1px solid var(--border-sidebar);
}
.docs-sidebar-group:last-child {
border-bottom: none;
margin-bottom: 0;
padding-bottom: 0;
}
.docs-sidebar-group-title {
display: flex;
align-items: center;
gap: 4px;
font-size: var(--font-size-sm);
font-weight: 500;
color: var(--text-secondary);
margin: 0 0 4px;
padding: 6px 8px;
cursor: pointer;
border-radius: 4px;
background: transparent;
border: none;
transition:
color 0.15s ease,
background-color 0.15s ease;
}
.docs-sidebar-group-title:hover {
color: var(--text-primary);
background-color: var(--bg-hover);
}
.docs-sidebar-group-title.expanded {
color: var(--text-primary);
}
.docs-sidebar-group-title svg {
width: 14px;
height: 14px;
transition: transform 0.15s ease;
flex-shrink: 0;
color: var(--text-muted);
}
.docs-sidebar-group-title.expanded svg {
transform: rotate(90deg);
color: var(--text-primary);
}
.docs-sidebar-group-list {
list-style: none;
padding: 0;
margin: 0 0 0 8px;
}
.docs-sidebar-item {
margin: 0;
}
.docs-sidebar-link {
display: block;
padding: 8px 12px 8px 20px;
color: var(--text-secondary);
text-decoration: none;
font-size: var(--font-size-sm);
border-radius: 6px;
border-left: 2px solid transparent;
margin-left: 4px;
transition:
color 0.15s ease,
background-color 0.15s ease,
border-color 0.15s ease;
}
.docs-sidebar-link:hover {
color: var(--text-primary);
background-color: var(--bg-hover);
}
.docs-sidebar-link.active {
color: var(--text-primary);
background-color: var(--bg-hover);
border-left-color: var(--accent-color, var(--text-primary));
font-weight: 500;
}
/* Docs TOC (right sidebar) */
.docs-toc {
padding-right: 8px;
}
.docs-toc-title {
font-size: var(--font-size-xs);
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
margin: 0 0 16px;
padding: 0 0 12px;
border-bottom: 1px solid var(--border-sidebar);
}
.docs-toc-list {
list-style: none;
padding: 0;
margin: 0;
}
.docs-toc-item {
margin: 0;
}
.docs-toc-link {
display: block;
padding: 6px 8px;
color: var(--text-secondary);
text-decoration: none;
font-size: var(--font-size-sm);
border-radius: 4px;
border-left: 2px solid transparent;
transition:
color 0.15s ease,
background-color 0.15s ease,
border-color 0.15s ease;
}
.docs-toc-link:hover {
color: var(--text-primary);
background-color: var(--bg-hover);
}
.docs-toc-link.active {
color: var(--text-primary);
background-color: var(--bg-hover);
border-left-color: var(--accent-color, var(--text-primary));
font-weight: 500;
}
/* TOC indentation levels */
.docs-toc-link.level-2 {
padding-left: 8px;
}
.docs-toc-link.level-3 {
padding-left: 20px;
font-size: var(--font-size-xs);
}
.docs-toc-link.level-4 {
padding-left: 32px;
font-size: var(--font-size-xs);
}
.docs-toc-link.level-5,
.docs-toc-link.level-6 {
padding-left: 44px;
font-size: var(--font-size-xs);
}
/* Docs page header */
.docs-page-header {
margin-bottom: 32px;
padding-bottom: 24px;
border-bottom: 1px solid var(--border-color);
}
.docs-page-title {
margin: 0 0 8px;
font-size: 2rem;
font-weight: 700;
color: var(--text-primary);
}
.docs-page-description {
margin: 0;
font-size: var(--font-size-lg);
color: var(--text-secondary);
}
/* Docs article styling */
.docs-article {
max-width: 800px;
margin: 0 auto;
}
/* Docs loading skeleton - prevents flash when navigating between docs pages */
.docs-article-loading {
padding: 32px 0;
}
.docs-loading-skeleton {
background: linear-gradient(
90deg,
var(--bg-secondary) 25%,
var(--bg-hover) 50%,
var(--bg-secondary) 75%
);
background-size: 200% 100%;
animation: docs-skeleton-pulse 1.5s ease-in-out infinite;
border-radius: 4px;
}
.docs-loading-title {
height: 40px;
width: 60%;
margin-bottom: 24px;
}
.docs-loading-text {
height: 16px;
width: 100%;
margin-bottom: 12px;
}
.docs-loading-text-short {
height: 16px;
width: 40%;
margin-bottom: 12px;
}
@keyframes docs-skeleton-pulse {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
/* Docs article actions (CopyPageDropdown) */
.docs-article-actions {
display: flex;
justify-content: flex-end;
margin-bottom: 16px;
}
.docs-article-header {
margin-bottom: 32px;
padding-bottom: 24px;
border-bottom: 1px solid var(--border-color);
}
.docs-article-title {
margin: 0 0 12px;
font-size: var(--font-size-post-title);
font-weight: 300;
letter-spacing: -0.02em;
color: var(--text-primary);
line-height: 1.2;
}
.docs-article-description {
margin: 0;
font-size: var(--font-size-lg);
color: var(--text-secondary);
line-height: 1.6;
}
/* Docs landing page content */
.docs-landing-content {
line-height: 1.7;
}
.docs-landing-list {
list-style: none;
padding: 0;
margin: 24px 0;
}
.docs-landing-item {
margin-bottom: 8px;
}
.docs-landing-link {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background-color: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
color: var(--text-primary);
text-decoration: none;
transition:
background-color 0.15s ease,
border-color 0.15s ease;
}
.docs-landing-link:hover {
background-color: var(--bg-hover);
border-color: var(--text-muted);
}
.docs-landing-link svg {
color: var(--text-muted);
}
.docs-landing-more {
color: var(--text-muted);
font-size: var(--font-size-sm);
}
.docs-landing-empty {
padding: 32px;
background-color: var(--bg-secondary);
border-radius: 8px;
text-align: center;
}
.docs-landing-empty code {
background-color: var(--bg-tertiary);
padding: 2px 6px;
border-radius: 4px;
font-size: var(--font-size-sm);
}
/* Docs responsive - tablet (hide right TOC) */
@media (max-width: 1200px) {
.docs-sidebar-right {
display: none;
}
}
/* Docs responsive - small tablet */
@media (max-width: 900px) {
.docs-sidebar-left {
width: 240px;
}
.docs-content {
padding: 24px 32px;
}
}
/* Docs responsive - mobile */
@media (max-width: 768px) {
.docs-layout,
.docs-layout.no-toc {
display: block;
padding: 0;
}
.docs-sidebar-left,
.docs-sidebar-right {
display: none;
}
.docs-content {
margin-left: 0;
margin-right: 0;
padding: 20px 16px;
min-height: auto;
}
.docs-article {
padding: 0;
}
.docs-article-title {
font-size: var(--font-size-2xl);
}
.docs-article-description {
font-size: var(--font-size-md);
}
}
/* Docs mobile sidebar (in hamburger menu) */
.docs-mobile-sidebar {
padding: 16px 0;
border-top: 1px solid var(--border-color);
margin-top: 16px;
}
.docs-mobile-sidebar .docs-sidebar-title {
padding: 0 0 12px;
}
.docs-mobile-sidebar .docs-sidebar-group {
margin-bottom: 16px;
}
.docs-mobile-sidebar .docs-sidebar-link {
padding: 8px 16px;
}