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

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