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:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user