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