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

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

View File

@@ -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 */}

View File

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

View File

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

View File

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

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

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

View File

@@ -31,6 +31,23 @@ export default function Layout({ children }: LayoutProps) {
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const 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">

View File

@@ -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">

View File

@@ -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
View File

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

View File

@@ -5,6 +5,7 @@ import BlogPost from "../components/BlogPost";
import CopyPageDropdown from "../components/CopyPageDropdown";
import 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) : [];

View File

@@ -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;
}