mirror of
https://github.com/waynesutton/markdown-site.git
synced 2026-01-12 04:09:14 +00:00
feat: Add docsSectionGroupOrder frontmatter field for controlling docs sidebar group order
This commit is contained in:
235
.cursor/plans/starlight-style_docs_section_68c9052e.plan.md
Normal file
235
.cursor/plans/starlight-style_docs_section_68c9052e.plan.md
Normal 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
|
||||
1
TASK.md
1
TASK.md
@@ -4,6 +4,7 @@
|
||||
|
||||
- [ ] docs pages
|
||||
- [ ] fix site confg link
|
||||
|
||||
- [ ] npm package
|
||||
|
||||
## Current Status
|
||||
|
||||
16
changelog.md
16
changelog.md
@@ -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/).
|
||||
|
||||
## [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
|
||||
|
||||
### Added
|
||||
|
||||
@@ -13,6 +13,10 @@ authorName: "Markdown"
|
||||
authorImage: "/images/authors/markdown.png"
|
||||
image: "/images/forkconfig.png"
|
||||
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
|
||||
@@ -98,7 +102,7 @@ If you prefer to update files manually, follow the guide in `FORK_CONFIG.md`. It
|
||||
The configuration script updates these files:
|
||||
|
||||
| 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/pages/Home.tsx` | Intro paragraph, footer links |
|
||||
| `src/pages/Post.tsx` | SITE_URL, SITE_NAME constants |
|
||||
|
||||
@@ -14,6 +14,13 @@ blogFeatured: true
|
||||
authorImage: "/images/authors/markdown.png"
|
||||
image: "/images/matthew-smith-Rfflri94rs8-unsplash.jpg"
|
||||
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
|
||||
|
||||
@@ -11,6 +11,10 @@ featuredOrder: 4
|
||||
layout: "sidebar"
|
||||
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."
|
||||
docsSection: true
|
||||
docsSectionOrder: 2
|
||||
docsSectionGroup: "Components"
|
||||
docsLanding: true
|
||||
---
|
||||
|
||||
# How to setup WorkOS
|
||||
|
||||
@@ -10,6 +10,10 @@ layout: "sidebar"
|
||||
blogFeatured: true
|
||||
image: /images/agentmail-blog.png
|
||||
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.
|
||||
|
||||
@@ -8,6 +8,10 @@ featured: true
|
||||
featuredOrder: 6
|
||||
image: /images/firecrwall-blog.png
|
||||
tags: ["tutorial", "firecrawl", "import"]
|
||||
docsSection: true
|
||||
docsSectionOrder: 2
|
||||
docsSectionGroup: "Components"
|
||||
docsLanding: true
|
||||
---
|
||||
|
||||
# How to use Firecrawl
|
||||
|
||||
@@ -8,6 +8,10 @@ published: true
|
||||
blogFeatured: true
|
||||
layout: "sidebar"
|
||||
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.
|
||||
|
||||
@@ -11,6 +11,10 @@ layout: "sidebar"
|
||||
featuredOrder: 2
|
||||
image: /images/dashboard.png
|
||||
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
|
||||
|
||||
@@ -12,6 +12,10 @@ featured: false
|
||||
layout: "sidebar"
|
||||
featuredOrder: 5
|
||||
image: "/images/markdown.png"
|
||||
docsSection: true
|
||||
docsSectionOrder: 3
|
||||
docsSectionGroup: "Publishing"
|
||||
docsLanding: true
|
||||
---
|
||||
|
||||
# Writing Markdown with Code Examples
|
||||
@@ -346,7 +350,8 @@ Embed a YouTube video using an iframe:
|
||||
height="315"
|
||||
src="https://www.youtube.com/embed/dQw4w9WgXcQ"
|
||||
title="YouTube video"
|
||||
allowfullscreen>
|
||||
allowfullscreen
|
||||
>
|
||||
</iframe>
|
||||
```
|
||||
|
||||
@@ -362,7 +367,8 @@ Embed a tweet using the Twitter embed URL:
|
||||
<iframe
|
||||
src="https://platform.twitter.com/embed/Tweet.html?id=20"
|
||||
width="550"
|
||||
height="250">
|
||||
height="250"
|
||||
>
|
||||
</iframe>
|
||||
```
|
||||
|
||||
@@ -380,7 +386,8 @@ Use `youtube-nocookie.com` for privacy-enhanced embeds:
|
||||
height="315"
|
||||
src="https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ"
|
||||
title="YouTube video"
|
||||
allowfullscreen>
|
||||
allowfullscreen
|
||||
>
|
||||
</iframe>
|
||||
```
|
||||
|
||||
|
||||
@@ -14,6 +14,10 @@ image: "/images/setupguide.png"
|
||||
authorName: "Markdown"
|
||||
authorImage: "/images/authors/markdown.png"
|
||||
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
|
||||
@@ -63,6 +67,7 @@ This guide walks you through forking [this markdown framework](https://github.co
|
||||
- [Visitor Map](#visitor-map)
|
||||
- [Logo Gallery](#logo-gallery)
|
||||
- [Blog page](#blog-page)
|
||||
- [Homepage Post Limit](#homepage-post-limit)
|
||||
- [Hardcoded Navigation Items](#hardcoded-navigation-items)
|
||||
- [Scroll-to-top button](#scroll-to-top-button)
|
||||
- [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)
|
||||
- [Update SEO Meta Tags](#update-seo-meta-tags)
|
||||
- [Update llms.txt and robots.txt](#update-llmstxt-and-robotstxt)
|
||||
- [Tag Pages and Related Posts](#tag-pages-and-related-posts)
|
||||
- [Search](#search)
|
||||
- [Using Search](#using-search)
|
||||
- [How It Works](#how-it-works)
|
||||
- [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)
|
||||
- [Mobile Navigation](#mobile-navigation)
|
||||
- [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)
|
||||
- [Write Page](#write-page)
|
||||
- [AI Agent chat](#ai-agent-chat)
|
||||
- [Dashboard](#dashboard)
|
||||
- [Next Steps](#next-steps)
|
||||
- [MCP Server](#mcp-server)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -337,7 +349,7 @@ Your markdown content here...
|
||||
### Frontmatter Fields
|
||||
|
||||
| Field | Required | Description |
|
||||
| --------------- | -------- | ----------------------------------------------------------------------------- |
|
||||
| --------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `title` | Yes | Post title |
|
||||
| `description` | Yes | Short description for SEO |
|
||||
| `date` | Yes | Publication date (YYYY-MM-DD) |
|
||||
@@ -353,6 +365,10 @@ Your markdown content here...
|
||||
| `authorImage` | No | Round author avatar image URL |
|
||||
| `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. |
|
||||
| `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
|
||||
|
||||
@@ -1592,7 +1608,7 @@ 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:
|
||||
|
||||
| Variable | Provider | Features |
|
||||
| --- | --- | --- |
|
||||
| ------------------- | --------- | ---------------------------------------- |
|
||||
| `ANTHROPIC_API_KEY` | Anthropic | Claude Sonnet 4 chat |
|
||||
| `OPENAI_API_KEY` | OpenAI | GPT-4o chat |
|
||||
| `GOOGLE_AI_API_KEY` | Google | Gemini 2.0 Flash chat + image generation |
|
||||
@@ -1680,6 +1696,7 @@ npm run sync-server
|
||||
```
|
||||
|
||||
This starts a local HTTP server on `localhost:3001` that:
|
||||
|
||||
- Executes sync commands when requested from the dashboard
|
||||
- Streams output in real-time to the dashboard terminal view
|
||||
- Shows server status (online/offline) in the dashboard
|
||||
|
||||
@@ -11,6 +11,10 @@ featured: false
|
||||
layout: "sidebar"
|
||||
newsletter: true
|
||||
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
|
||||
|
||||
@@ -14,6 +14,10 @@ showImageAtTop: true
|
||||
authorName: "Markdown"
|
||||
authorImage: "/images/authors/markdown.png"
|
||||
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
|
||||
|
||||
@@ -5,11 +5,42 @@ published: true
|
||||
order: 5
|
||||
rightSidebar: false
|
||||
layout: "sidebar"
|
||||
docsSection: true
|
||||
docsSectionOrder: 4
|
||||
---
|
||||
|
||||
All notable changes to this project.
|
||||

|
||||
|
||||
## 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
|
||||
|
||||
Released January 1, 2026
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
---
|
||||
title: "Docs"
|
||||
slug: "docs"
|
||||
title: "Documentation"
|
||||
slug: "documentation"
|
||||
published: true
|
||||
order: 0
|
||||
showInNav: false
|
||||
layout: "sidebar"
|
||||
aiChat: true
|
||||
rightSidebar: true
|
||||
showFooter: true
|
||||
docsSection: true
|
||||
docsSectionOrder: 1
|
||||
docsSectionGroup: "Setup"
|
||||
docsLanding: true
|
||||
---
|
||||
|
||||
## Getting Started
|
||||
@@ -130,6 +135,10 @@ Content here...
|
||||
| `newsletter` | No | Override newsletter signup display (`true` to show, `false` to hide) |
|
||||
| `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. |
|
||||
| `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`) |
|
||||
|
||||
### Static pages
|
||||
@@ -173,6 +182,10 @@ Content here...
|
||||
| `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`) |
|
||||
| `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.
|
||||
|
||||
@@ -1118,7 +1131,7 @@ The Dashboard includes a dedicated AI Agent section with tab-based UI for Chat a
|
||||
**Environment Variables (Convex):**
|
||||
|
||||
| Variable | Description |
|
||||
| --- | --- |
|
||||
| ------------------- | -------------------------------------------------- |
|
||||
| `ANTHROPIC_API_KEY` | Required for Claude Sonnet 4 |
|
||||
| `OPENAI_API_KEY` | Required for GPT-4o |
|
||||
| `GOOGLE_AI_API_KEY` | Required for Gemini 2.0 Flash and image generation |
|
||||
|
||||
@@ -184,6 +184,7 @@ export const getPageBySlug = query({
|
||||
contactForm: v.optional(v.boolean()),
|
||||
newsletter: v.optional(v.boolean()),
|
||||
textAlign: v.optional(v.string()),
|
||||
docsSection: v.optional(v.boolean()),
|
||||
}),
|
||||
v.null(),
|
||||
),
|
||||
@@ -221,6 +222,94 @@ export const getPageBySlug = query({
|
||||
contactForm: page.contactForm,
|
||||
newsletter: page.newsletter,
|
||||
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()),
|
||||
newsletter: v.optional(v.boolean()),
|
||||
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,
|
||||
newsletter: page.newsletter,
|
||||
textAlign: page.textAlign,
|
||||
docsSection: page.docsSection,
|
||||
docsSectionGroup: page.docsSectionGroup,
|
||||
docsSectionOrder: page.docsSectionOrder,
|
||||
docsSectionGroupOrder: page.docsSectionGroupOrder,
|
||||
docsLanding: page.docsLanding,
|
||||
lastSyncedAt: now,
|
||||
});
|
||||
updated++;
|
||||
|
||||
117
convex/posts.ts
117
convex/posts.ts
@@ -238,6 +238,7 @@ export const getPostBySlug = query({
|
||||
aiChat: v.optional(v.boolean()),
|
||||
newsletter: v.optional(v.boolean()),
|
||||
contactForm: v.optional(v.boolean()),
|
||||
docsSection: v.optional(v.boolean()),
|
||||
}),
|
||||
v.null(),
|
||||
),
|
||||
@@ -277,6 +278,7 @@ export const getPostBySlug = query({
|
||||
aiChat: post.aiChat,
|
||||
newsletter: post.newsletter,
|
||||
contactForm: post.contactForm,
|
||||
docsSection: post.docsSection,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -384,6 +386,11 @@ export const syncPosts = internalMutation({
|
||||
newsletter: v.optional(v.boolean()),
|
||||
contactForm: 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,
|
||||
contactForm: post.contactForm,
|
||||
unlisted: post.unlisted,
|
||||
docsSection: post.docsSection,
|
||||
docsSectionGroup: post.docsSectionGroup,
|
||||
docsSectionOrder: post.docsSectionOrder,
|
||||
docsSectionGroupOrder: post.docsSectionGroupOrder,
|
||||
docsLanding: post.docsLanding,
|
||||
lastSyncedAt: now,
|
||||
});
|
||||
updated++;
|
||||
@@ -490,6 +502,11 @@ export const syncPostsPublic = mutation({
|
||||
newsletter: v.optional(v.boolean()),
|
||||
contactForm: 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,
|
||||
contactForm: post.contactForm,
|
||||
unlisted: post.unlisted,
|
||||
docsSection: post.docsSection,
|
||||
docsSectionGroup: post.docsSectionGroup,
|
||||
docsSectionOrder: post.docsSectionOrder,
|
||||
docsSectionGroupOrder: post.docsSectionGroupOrder,
|
||||
docsLanding: post.docsLanding,
|
||||
lastSyncedAt: now,
|
||||
});
|
||||
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,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -29,6 +29,11 @@ export default defineSchema({
|
||||
newsletter: v.optional(v.boolean()), // Override newsletter signup display (true/false)
|
||||
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
|
||||
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(),
|
||||
})
|
||||
.index("by_slug", ["slug"])
|
||||
@@ -37,6 +42,7 @@ export default defineSchema({
|
||||
.index("by_featured", ["featured"])
|
||||
.index("by_blogFeatured", ["blogFeatured"])
|
||||
.index("by_authorName", ["authorName"])
|
||||
.index("by_docsSection", ["docsSection"])
|
||||
.searchIndex("search_content", {
|
||||
searchField: "content",
|
||||
filterFields: ["published"],
|
||||
@@ -70,11 +76,17 @@ export default defineSchema({
|
||||
contactForm: v.optional(v.boolean()), // Enable contact form on this page
|
||||
newsletter: v.optional(v.boolean()), // Override newsletter signup display (true/false)
|
||||
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(),
|
||||
})
|
||||
.index("by_slug", ["slug"])
|
||||
.index("by_published", ["published"])
|
||||
.index("by_featured", ["featured"])
|
||||
.index("by_docsSection", ["docsSection"])
|
||||
.searchIndex("search_content", {
|
||||
searchField: "content",
|
||||
filterFields: ["published"],
|
||||
|
||||
8
files.md
8
files.md
@@ -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) |
|
||||
| `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. |
|
||||
| `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/`)
|
||||
|
||||
@@ -203,6 +207,10 @@ Markdown files for static pages like About, Projects, Contact, Changelog.
|
||||
| `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. |
|
||||
| `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/`)
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
---
|
||||
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.
|
||||
|
||||
@@ -2,12 +2,41 @@
|
||||
|
||||
---
|
||||
Type: page
|
||||
Date: 2026-01-02
|
||||
Date: 2026-01-03
|
||||
---
|
||||
|
||||
All notable changes to this project.
|
||||

|
||||
|
||||
## 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
|
||||
|
||||
Released January 1, 2026
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
---
|
||||
Type: page
|
||||
Date: 2026-01-02
|
||||
Date: 2026-01-03
|
||||
---
|
||||
|
||||
You found the contact page. Nice
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Docs
|
||||
# Documentation
|
||||
|
||||
---
|
||||
Type: page
|
||||
Date: 2026-01-02
|
||||
Date: 2026-01-03
|
||||
---
|
||||
|
||||
## Getting Started
|
||||
@@ -126,6 +126,10 @@ Content here...
|
||||
| `newsletter` | No | Override newsletter signup display (`true` to show, `false` to hide) |
|
||||
| `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. |
|
||||
| `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`) |
|
||||
|
||||
### Static pages
|
||||
@@ -169,6 +173,10 @@ Content here...
|
||||
| `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`) |
|
||||
| `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.
|
||||
|
||||
@@ -1114,7 +1122,7 @@ The Dashboard includes a dedicated AI Agent section with tab-based UI for Chat a
|
||||
**Environment Variables (Convex):**
|
||||
|
||||
| Variable | Description |
|
||||
| --- | --- |
|
||||
| ------------------- | -------------------------------------------------- |
|
||||
| `ANTHROPIC_API_KEY` | Required for Claude Sonnet 4 |
|
||||
| `OPENAI_API_KEY` | Required for GPT-4o |
|
||||
| `GOOGLE_AI_API_KEY` | Required for Gemini 2.0 Flash and image generation |
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
---
|
||||
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).
|
||||
|
||||
@@ -92,7 +92,7 @@ If you prefer to update files manually, follow the guide in `FORK_CONFIG.md`. It
|
||||
The configuration script updates these files:
|
||||
|
||||
| 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/pages/Home.tsx` | Intro paragraph, footer links |
|
||||
| `src/pages/Post.tsx` | SITE_URL, SITE_NAME constants |
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
---
|
||||
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](/)**.
|
||||
|
||||
@@ -9,6 +9,8 @@ Reading time: 3 min read
|
||||
Tags: tutorial, markdown, cursor, IDE, publishing
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
# How to Publish a Blog Post
|
||||
|
||||

|
||||
|
||||
@@ -45,7 +45,7 @@ This is the homepage index of all published content.
|
||||
|
||||
- **[Footer](/raw/footer.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.
|
||||
- **[Projects](/raw/projects.md)**
|
||||
- **[Contact](/raw/contact.md)**
|
||||
|
||||
@@ -341,7 +341,8 @@ Embed a YouTube video using an iframe:
|
||||
height="315"
|
||||
src="https://www.youtube.com/embed/dQw4w9WgXcQ"
|
||||
title="YouTube video"
|
||||
allowfullscreen>
|
||||
allowfullscreen
|
||||
>
|
||||
</iframe>
|
||||
```
|
||||
|
||||
@@ -357,7 +358,8 @@ Embed a tweet using the Twitter embed URL:
|
||||
<iframe
|
||||
src="https://platform.twitter.com/embed/Tweet.html?id=20"
|
||||
width="550"
|
||||
height="250">
|
||||
height="250"
|
||||
>
|
||||
</iframe>
|
||||
```
|
||||
|
||||
@@ -375,7 +377,8 @@ Use `youtube-nocookie.com` for privacy-enhanced embeds:
|
||||
height="315"
|
||||
src="https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ"
|
||||
title="YouTube video"
|
||||
allowfullscreen>
|
||||
allowfullscreen
|
||||
>
|
||||
</iframe>
|
||||
```
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
---
|
||||
Type: page
|
||||
Date: 2026-01-02
|
||||
Date: 2026-01-03
|
||||
---
|
||||
|
||||
# Newsletter Demo Page
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
---
|
||||
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.
|
||||
|
||||
@@ -56,6 +56,7 @@ This guide walks you through forking [this markdown framework](https://github.co
|
||||
- [Visitor Map](#visitor-map)
|
||||
- [Logo Gallery](#logo-gallery)
|
||||
- [Blog page](#blog-page)
|
||||
- [Homepage Post Limit](#homepage-post-limit)
|
||||
- [Hardcoded Navigation Items](#hardcoded-navigation-items)
|
||||
- [Scroll-to-top button](#scroll-to-top-button)
|
||||
- [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)
|
||||
- [Update SEO Meta Tags](#update-seo-meta-tags)
|
||||
- [Update llms.txt and robots.txt](#update-llmstxt-and-robotstxt)
|
||||
- [Tag Pages and Related Posts](#tag-pages-and-related-posts)
|
||||
- [Search](#search)
|
||||
- [Using Search](#using-search)
|
||||
- [How It Works](#how-it-works)
|
||||
- [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)
|
||||
- [Mobile Navigation](#mobile-navigation)
|
||||
- [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)
|
||||
- [Write Page](#write-page)
|
||||
- [AI Agent chat](#ai-agent-chat)
|
||||
- [Dashboard](#dashboard)
|
||||
- [Next Steps](#next-steps)
|
||||
- [MCP Server](#mcp-server)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -330,7 +338,7 @@ Your markdown content here...
|
||||
### Frontmatter Fields
|
||||
|
||||
| Field | Required | Description |
|
||||
| --------------- | -------- | ----------------------------------------------------------------------------- |
|
||||
| --------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `title` | Yes | Post title |
|
||||
| `description` | Yes | Short description for SEO |
|
||||
| `date` | Yes | Publication date (YYYY-MM-DD) |
|
||||
@@ -346,6 +354,10 @@ Your markdown content here...
|
||||
| `authorImage` | No | Round author avatar image URL |
|
||||
| `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. |
|
||||
| `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
|
||||
|
||||
@@ -1585,7 +1597,7 @@ 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:
|
||||
|
||||
| Variable | Provider | Features |
|
||||
| --- | --- | --- |
|
||||
| ------------------- | --------- | ---------------------------------------- |
|
||||
| `ANTHROPIC_API_KEY` | Anthropic | Claude Sonnet 4 chat |
|
||||
| `OPENAI_API_KEY` | OpenAI | GPT-4o chat |
|
||||
| `GOOGLE_AI_API_KEY` | Google | Gemini 2.0 Flash chat + image generation |
|
||||
@@ -1673,6 +1685,7 @@ npm run sync-server
|
||||
```
|
||||
|
||||
This starts a local HTTP server on `localhost:3001` that:
|
||||
|
||||
- Executes sync commands when requested from the dashboard
|
||||
- Streams output in real-time to the dashboard terminal view
|
||||
- Shows server status (online/offline) in the dashboard
|
||||
|
||||
@@ -46,6 +46,11 @@ interface PostFrontmatter {
|
||||
blogFeatured?: boolean; // Show as hero featured post on /blog page
|
||||
newsletter?: boolean; // Override newsletter signup display (true/false)
|
||||
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 {
|
||||
@@ -74,6 +79,11 @@ interface ParsedPost {
|
||||
newsletter?: boolean; // Override newsletter signup display (true/false)
|
||||
contactForm?: boolean; // Enable contact form on this post
|
||||
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)
|
||||
@@ -99,6 +109,11 @@ interface PageFrontmatter {
|
||||
contactForm?: boolean; // Enable contact form on this page
|
||||
newsletter?: boolean; // Override newsletter signup display (true/false)
|
||||
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 {
|
||||
@@ -124,6 +139,11 @@ interface ParsedPage {
|
||||
contactForm?: boolean; // Enable contact form on this page
|
||||
newsletter?: boolean; // Override newsletter signup display (true/false)
|
||||
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
|
||||
@@ -174,6 +194,11 @@ function parseMarkdownFile(filePath: string): ParsedPost | null {
|
||||
newsletter: frontmatter.newsletter, // Override newsletter signup display
|
||||
contactForm: frontmatter.contactForm, // Enable contact form on this post
|
||||
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) {
|
||||
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
|
||||
newsletter: frontmatter.newsletter, // Override newsletter signup display
|
||||
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) {
|
||||
console.error(`Error parsing page ${filePath}:`, error);
|
||||
|
||||
@@ -3,6 +3,7 @@ import Home from "./pages/Home";
|
||||
import Post from "./pages/Post";
|
||||
import Stats from "./pages/Stats";
|
||||
import Blog from "./pages/Blog";
|
||||
import DocsPage from "./pages/DocsPage";
|
||||
import Write from "./pages/Write";
|
||||
import TagPage from "./pages/TagPage";
|
||||
import AuthorPage from "./pages/AuthorPage";
|
||||
@@ -86,6 +87,13 @@ function App() {
|
||||
{siteConfig.blogPage.enabled && (
|
||||
<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 */}
|
||||
<Route path="/tags/:tag" element={<TagPage />} />
|
||||
{/* Author page route - displays posts by a specific author */}
|
||||
|
||||
@@ -34,11 +34,17 @@ const sanitizeSchema = {
|
||||
div: ["style"], // Allow inline styles on div for grid layouts
|
||||
p: ["style"], // Allow inline styles on p elements
|
||||
a: ["style", "href", "target", "rel"], // Allow inline styles on links
|
||||
img: [
|
||||
...(defaultSchema.attributes?.img || []),
|
||||
img: [...(defaultSchema.attributes?.img || []), "style"], // Allow inline styles on images
|
||||
iframe: [
|
||||
"src",
|
||||
"width",
|
||||
"height",
|
||||
"allow",
|
||||
"allowfullscreen",
|
||||
"frameborder",
|
||||
"title",
|
||||
"style",
|
||||
], // Allow inline styles on images
|
||||
iframe: ["src", "width", "height", "allow", "allowfullscreen", "frameborder", "title", "style"], // Allow iframe with specific attributes
|
||||
], // Allow iframe with specific attributes
|
||||
},
|
||||
};
|
||||
|
||||
@@ -354,8 +360,14 @@ function stripHtmlComments(content: string): string {
|
||||
let processed = content;
|
||||
|
||||
// Replace special placeholders with markers
|
||||
processed = processed.replace(/<!--\s*newsletter\s*-->/gi, markers.newsletter);
|
||||
processed = processed.replace(/<!--\s*contactform\s*-->/gi, markers.contactform);
|
||||
processed = processed.replace(
|
||||
/<!--\s*newsletter\s*-->/gi,
|
||||
markers.newsletter,
|
||||
);
|
||||
processed = processed.replace(
|
||||
/<!--\s*contactform\s*-->/gi,
|
||||
markers.contactform,
|
||||
);
|
||||
|
||||
// Remove all remaining HTML comments (including multi-line)
|
||||
processed = processed.replace(/<!--[\s\S]*?-->/g, "");
|
||||
@@ -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 [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 getCodeTheme = () => {
|
||||
@@ -492,10 +511,14 @@ export default function BlogPost({ content, slug, pageType = "post" }: BlogPostP
|
||||
rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema]]}
|
||||
components={{
|
||||
code(codeProps) {
|
||||
const { className, children, node, style, ...restProps } = codeProps as {
|
||||
const { className, children, node, style, ...restProps } =
|
||||
codeProps as {
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
node?: { tagName?: string; properties?: { className?: string[] } };
|
||||
node?: {
|
||||
tagName?: string;
|
||||
properties?: { className?: string[] };
|
||||
};
|
||||
style?: React.CSSProperties;
|
||||
inline?: boolean;
|
||||
};
|
||||
@@ -503,7 +526,7 @@ export default function BlogPost({ content, slug, pageType = "post" }: BlogPostP
|
||||
|
||||
// Detect inline code: no language class AND content is short without newlines
|
||||
const codeContent = String(children);
|
||||
const hasNewlines = codeContent.includes('\n');
|
||||
const hasNewlines = codeContent.includes("\n");
|
||||
const isShort = codeContent.length < 80;
|
||||
const hasLanguage = !!match || !!className;
|
||||
|
||||
@@ -523,14 +546,18 @@ export default function BlogPost({ content, slug, pageType = "post" }: BlogPostP
|
||||
const isTextBlock = language === "text";
|
||||
|
||||
// Custom styles for text blocks to enable wrapping
|
||||
const textBlockStyle = isTextBlock ? {
|
||||
const textBlockStyle = isTextBlock
|
||||
? {
|
||||
whiteSpace: "pre-wrap" as const,
|
||||
wordWrap: "break-word" as const,
|
||||
overflowWrap: "break-word" as const,
|
||||
} : {};
|
||||
}
|
||||
: {};
|
||||
|
||||
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>}
|
||||
<CodeCopyButton code={codeString} />
|
||||
<SyntaxHighlighter
|
||||
@@ -538,7 +565,9 @@ export default function BlogPost({ content, slug, pageType = "post" }: BlogPostP
|
||||
language={language}
|
||||
PreTag="div"
|
||||
customStyle={textBlockStyle}
|
||||
codeTagProps={isTextBlock ? { style: textBlockStyle } : undefined}
|
||||
codeTagProps={
|
||||
isTextBlock ? { style: textBlockStyle } : undefined
|
||||
}
|
||||
>
|
||||
{codeString}
|
||||
</SyntaxHighlighter>
|
||||
@@ -681,7 +710,7 @@ export default function BlogPost({ content, slug, pageType = "post" }: BlogPostP
|
||||
const url = new URL(src);
|
||||
const isAllowed = ALLOWED_IFRAME_DOMAINS.some(
|
||||
(domain) =>
|
||||
url.hostname === domain || url.hostname.endsWith("." + domain)
|
||||
url.hostname === domain || url.hostname.endsWith("." + domain),
|
||||
);
|
||||
if (!isAllowed) return null;
|
||||
|
||||
@@ -727,10 +756,7 @@ export default function BlogPost({ content, slug, pageType = "post" }: BlogPostP
|
||||
if (segment.type === "contactform") {
|
||||
// Contact form inline
|
||||
return siteConfig.contactForm?.enabled ? (
|
||||
<ContactForm
|
||||
key={`contactform-${index}`}
|
||||
source={source}
|
||||
/>
|
||||
<ContactForm key={`contactform-${index}`} source={source} />
|
||||
) : null;
|
||||
}
|
||||
// Markdown content segment
|
||||
@@ -757,10 +783,14 @@ export default function BlogPost({ content, slug, pageType = "post" }: BlogPostP
|
||||
rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema]]}
|
||||
components={{
|
||||
code(codeProps) {
|
||||
const { className, children, node, style, ...restProps } = codeProps as {
|
||||
const { className, children, node, style, ...restProps } =
|
||||
codeProps as {
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
node?: { tagName?: string; properties?: { className?: string[] } };
|
||||
node?: {
|
||||
tagName?: string;
|
||||
properties?: { className?: string[] };
|
||||
};
|
||||
style?: React.CSSProperties;
|
||||
inline?: boolean;
|
||||
};
|
||||
@@ -769,7 +799,7 @@ export default function BlogPost({ content, slug, pageType = "post" }: BlogPostP
|
||||
// Detect inline code: no language class AND content is short without newlines
|
||||
// Fenced code blocks (even without language) are longer or have structure
|
||||
const codeContent = String(children);
|
||||
const hasNewlines = codeContent.includes('\n');
|
||||
const hasNewlines = codeContent.includes("\n");
|
||||
const isShort = codeContent.length < 80;
|
||||
const hasLanguage = !!match || !!className;
|
||||
|
||||
@@ -789,14 +819,18 @@ export default function BlogPost({ content, slug, pageType = "post" }: BlogPostP
|
||||
const isTextBlock = language === "text";
|
||||
|
||||
// Custom styles for text blocks to enable wrapping
|
||||
const textBlockStyle = isTextBlock ? {
|
||||
const textBlockStyle = isTextBlock
|
||||
? {
|
||||
whiteSpace: "pre-wrap" as const,
|
||||
wordWrap: "break-word" as const,
|
||||
overflowWrap: "break-word" as const,
|
||||
} : {};
|
||||
}
|
||||
: {};
|
||||
|
||||
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>}
|
||||
<CodeCopyButton code={codeString} />
|
||||
<SyntaxHighlighter
|
||||
@@ -804,7 +838,9 @@ export default function BlogPost({ content, slug, pageType = "post" }: BlogPostP
|
||||
language={language}
|
||||
PreTag="div"
|
||||
customStyle={textBlockStyle}
|
||||
codeTagProps={isTextBlock ? { style: textBlockStyle } : undefined}
|
||||
codeTagProps={
|
||||
isTextBlock ? { style: textBlockStyle } : undefined
|
||||
}
|
||||
>
|
||||
{codeString}
|
||||
</SyntaxHighlighter>
|
||||
@@ -825,7 +861,9 @@ export default function BlogPost({ content, slug, pageType = "post" }: BlogPostP
|
||||
className={`blog-image ${isLightboxEnabled ? "blog-image-clickable" : ""}`}
|
||||
loading="lazy"
|
||||
onClick={isLightboxEnabled ? handleImageClick : undefined}
|
||||
style={isLightboxEnabled ? { cursor: "pointer" } : undefined}
|
||||
style={
|
||||
isLightboxEnabled ? { cursor: "pointer" } : undefined
|
||||
}
|
||||
/>
|
||||
{alt && <span className="blog-image-caption">{alt}</span>}
|
||||
</span>
|
||||
@@ -947,7 +985,8 @@ export default function BlogPost({ content, slug, pageType = "post" }: BlogPostP
|
||||
const url = new URL(src);
|
||||
const isAllowed = ALLOWED_IFRAME_DOMAINS.some(
|
||||
(domain) =>
|
||||
url.hostname === domain || url.hostname.endsWith("." + domain)
|
||||
url.hostname === domain ||
|
||||
url.hostname.endsWith("." + domain),
|
||||
);
|
||||
if (!isAllowed) return null;
|
||||
|
||||
|
||||
101
src/components/DocsLayout.tsx
Normal file
101
src/components/DocsLayout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
213
src/components/DocsSidebar.tsx
Normal file
213
src/components/DocsSidebar.tsx
Normal 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
129
src/components/DocsTOC.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -31,6 +31,23 @@ export default function Layout({ children }: LayoutProps) {
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
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)
|
||||
const sidebarContext = useSidebarOptional();
|
||||
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)
|
||||
if (siteConfig.hardcodedNavItems && siteConfig.hardcodedNavItems.length > 0) {
|
||||
siteConfig.hardcodedNavItems.forEach((item) => {
|
||||
@@ -236,6 +262,8 @@ export default function Layout({ children }: LayoutProps) {
|
||||
onClose={closeMobileMenu}
|
||||
sidebarHeadings={sidebarHeadings}
|
||||
sidebarActiveId={sidebarActiveId}
|
||||
showDocsNav={isDocsPage}
|
||||
currentDocsSlug={currentSlug}
|
||||
>
|
||||
{/* Page navigation links in mobile menu (same order as desktop) */}
|
||||
<nav className="mobile-nav-links">
|
||||
|
||||
@@ -2,6 +2,8 @@ import { ReactNode, useEffect, useRef, useCallback } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { Heading } from "../utils/extractHeadings";
|
||||
import DocsSidebar from "./DocsSidebar";
|
||||
import siteConfig from "../config/siteConfig";
|
||||
|
||||
interface MobileMenuProps {
|
||||
isOpen: boolean;
|
||||
@@ -9,6 +11,8 @@ interface MobileMenuProps {
|
||||
children: ReactNode;
|
||||
sidebarHeadings?: Heading[];
|
||||
sidebarActiveId?: string;
|
||||
showDocsNav?: boolean;
|
||||
currentDocsSlug?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -22,9 +26,12 @@ export default function MobileMenu({
|
||||
children,
|
||||
sidebarHeadings = [],
|
||||
sidebarActiveId,
|
||||
showDocsNav = false,
|
||||
currentDocsSlug,
|
||||
}: MobileMenuProps) {
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const hasSidebar = sidebarHeadings.length > 0;
|
||||
const showDocsSection = showDocsNav && siteConfig.docsSection?.enabled;
|
||||
|
||||
// Handle escape key to close menu
|
||||
useEffect(() => {
|
||||
@@ -136,6 +143,13 @@ export default function MobileMenu({
|
||||
<div className="mobile-menu-content">
|
||||
{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) */}
|
||||
{hasSidebar && (
|
||||
<div className="mobile-menu-toc">
|
||||
|
||||
@@ -177,6 +177,18 @@ export interface StatsPageConfig {
|
||||
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
|
||||
// Sends developer notifications for subscriber events
|
||||
// Uses AGENTMAIL_CONTACT_EMAIL or AGENTMAIL_INBOX as recipient
|
||||
@@ -325,6 +337,9 @@ export interface SiteConfig {
|
||||
// Stats page configuration (optional)
|
||||
statsPage?: StatsPageConfig;
|
||||
|
||||
// Docs section configuration (optional)
|
||||
docsSection?: DocsSectionConfig;
|
||||
|
||||
// Newsletter notifications configuration (optional)
|
||||
newsletterNotifications?: NewsletterNotificationsConfig;
|
||||
|
||||
@@ -620,6 +635,19 @@ export const siteConfig: SiteConfig = {
|
||||
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
|
||||
// Sends developer notifications for subscriber events via AgentMail
|
||||
newsletterNotifications: {
|
||||
|
||||
139
src/pages/DocsPage.tsx
Normal file
139
src/pages/DocsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import BlogPost from "../components/BlogPost";
|
||||
import CopyPageDropdown from "../components/CopyPageDropdown";
|
||||
import PageSidebar from "../components/PageSidebar";
|
||||
import RightSidebar from "../components/RightSidebar";
|
||||
import DocsLayout from "../components/DocsLayout";
|
||||
import Footer from "../components/Footer";
|
||||
import SocialFooter from "../components/SocialFooter";
|
||||
import NewsletterSignup from "../components/NewsletterSignup";
|
||||
@@ -196,13 +197,79 @@ export default function Post({
|
||||
};
|
||||
}, [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)
|
||||
// But for docs pages, show skeleton within DocsLayout to prevent sidebar flash
|
||||
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;
|
||||
}
|
||||
|
||||
// If it's a static page, render simplified view
|
||||
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")
|
||||
const headings =
|
||||
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")
|
||||
const headings =
|
||||
post?.layout === "sidebar" ? extractHeadings(post.content) : [];
|
||||
|
||||
@@ -4827,6 +4827,37 @@ body {
|
||||
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-toc {
|
||||
margin-top: 16px;
|
||||
@@ -11937,3 +11968,589 @@ body {
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user