Files
wiki/src/components/BlogPost.tsx

458 lines
14 KiB
TypeScript
Raw Normal View History

import React, { useState } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import remarkBreaks from "remark-breaks";
import rehypeRaw from "rehype-raw";
import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { Copy, Check } from "lucide-react";
import { useTheme } from "../context/ThemeContext";
// Sanitize schema that allows collapsible sections (details/summary)
const sanitizeSchema = {
...defaultSchema,
tagNames: [...(defaultSchema.tagNames || []), "details", "summary"],
attributes: {
...defaultSchema.attributes,
details: ["open"], // Allow the 'open' attribute for expanded by default
},
};
// Copy button component for code blocks
function CodeCopyButton({ code }: { code: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
await navigator.clipboard.writeText(code);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<button
className="code-copy-button"
onClick={handleCopy}
aria-label={copied ? "Copied!" : "Copy code"}
title={copied ? "Copied!" : "Copy code"}
>
{copied ? <Check size={14} /> : <Copy size={14} />}
</button>
);
}
// Cursor Dark Theme colors for syntax highlighting
const cursorDarkTheme: { [key: string]: React.CSSProperties } = {
'code[class*="language-"]': {
color: "#d4d4d4",
background: "#1e1e1e",
fontFamily:
"SF Mono, Monaco, Cascadia Code, Roboto Mono, Consolas, Courier New, monospace",
fontSize: "14px",
textAlign: "left" as const,
whiteSpace: "pre" as const,
wordSpacing: "normal",
wordBreak: "normal" as const,
wordWrap: "normal" as const,
lineHeight: "1.6",
tabSize: 4,
hyphens: "none" as const,
},
'pre[class*="language-"]': {
color: "#d4d4d4",
background: "#1e1e1e",
fontFamily:
"SF Mono, Monaco, Cascadia Code, Roboto Mono, Consolas, Courier New, monospace",
fontSize: "14px",
textAlign: "left" as const,
whiteSpace: "pre" as const,
wordSpacing: "normal",
wordBreak: "normal" as const,
wordWrap: "normal" as const,
lineHeight: "1.6",
tabSize: 4,
hyphens: "none" as const,
padding: "1.5em",
margin: "1.5em 0",
overflow: "auto" as const,
borderRadius: "8px",
},
comment: { color: "#6a9955", fontStyle: "italic" },
prolog: { color: "#6a9955" },
doctype: { color: "#6a9955" },
cdata: { color: "#6a9955" },
punctuation: { color: "#d4d4d4" },
property: { color: "#9cdcfe" },
tag: { color: "#569cd6" },
boolean: { color: "#569cd6" },
number: { color: "#b5cea8" },
constant: { color: "#4fc1ff" },
symbol: { color: "#4fc1ff" },
deleted: { color: "#f44747" },
selector: { color: "#d7ba7d" },
"attr-name": { color: "#92c5f6" },
string: { color: "#ce9178" },
char: { color: "#ce9178" },
builtin: { color: "#569cd6" },
inserted: { color: "#6a9955" },
operator: { color: "#d4d4d4" },
entity: { color: "#dcdcaa" },
url: { color: "#9cdcfe", textDecoration: "underline" },
variable: { color: "#9cdcfe" },
atrule: { color: "#569cd6" },
"attr-value": { color: "#ce9178" },
function: { color: "#dcdcaa" },
"function-variable": { color: "#dcdcaa" },
keyword: { color: "#569cd6" },
regex: { color: "#d16969" },
important: { color: "#569cd6", fontWeight: "bold" },
bold: { fontWeight: "bold" },
italic: { fontStyle: "italic" },
namespace: { opacity: 0.7 },
"class-name": { color: "#4ec9b0" },
parameter: { color: "#9cdcfe" },
decorator: { color: "#dcdcaa" },
};
// Cursor Light Theme colors for syntax highlighting
const cursorLightTheme: { [key: string]: React.CSSProperties } = {
'code[class*="language-"]': {
color: "#171717",
background: "#f5f5f5",
fontFamily:
"SF Mono, Monaco, Cascadia Code, Roboto Mono, Consolas, Courier New, monospace",
fontSize: "14px",
textAlign: "left" as const,
whiteSpace: "pre" as const,
wordSpacing: "normal",
wordBreak: "normal" as const,
wordWrap: "normal" as const,
lineHeight: "1.6",
tabSize: 4,
hyphens: "none" as const,
},
'pre[class*="language-"]': {
color: "#171717",
background: "#f5f5f5",
fontFamily:
"SF Mono, Monaco, Cascadia Code, Roboto Mono, Consolas, Courier New, monospace",
fontSize: "14px",
textAlign: "left" as const,
whiteSpace: "pre" as const,
wordSpacing: "normal",
wordBreak: "normal" as const,
wordWrap: "normal" as const,
lineHeight: "1.6",
tabSize: 4,
hyphens: "none" as const,
padding: "1.5em",
margin: "1.5em 0",
overflow: "auto" as const,
borderRadius: "8px",
},
comment: { color: "#6a737d", fontStyle: "italic" },
prolog: { color: "#6a737d" },
doctype: { color: "#6a737d" },
cdata: { color: "#6a737d" },
punctuation: { color: "#24292e" },
property: { color: "#005cc5" },
tag: { color: "#22863a" },
boolean: { color: "#005cc5" },
number: { color: "#005cc5" },
constant: { color: "#005cc5" },
symbol: { color: "#e36209" },
deleted: { color: "#b31d28", background: "#ffeef0" },
selector: { color: "#22863a" },
"attr-name": { color: "#6f42c1" },
string: { color: "#032f62" },
char: { color: "#032f62" },
builtin: { color: "#005cc5" },
inserted: { color: "#22863a", background: "#f0fff4" },
operator: { color: "#d73a49" },
entity: { color: "#6f42c1" },
url: { color: "#005cc5", textDecoration: "underline" },
variable: { color: "#e36209" },
atrule: { color: "#005cc5" },
"attr-value": { color: "#032f62" },
function: { color: "#6f42c1" },
"function-variable": { color: "#6f42c1" },
keyword: { color: "#d73a49" },
regex: { color: "#032f62" },
important: { color: "#d73a49", fontWeight: "bold" },
bold: { fontWeight: "bold" },
italic: { fontStyle: "italic" },
namespace: { opacity: 0.7 },
"class-name": { color: "#6f42c1" },
parameter: { color: "#24292e" },
decorator: { color: "#6f42c1" },
};
// Tan Theme colors for syntax highlighting
const cursorTanTheme: { [key: string]: React.CSSProperties } = {
'code[class*="language-"]': {
color: "#1a1a1a",
background: "#f0ece4",
fontFamily:
"SF Mono, Monaco, Cascadia Code, Roboto Mono, Consolas, Courier New, monospace",
fontSize: "14px",
textAlign: "left" as const,
whiteSpace: "pre" as const,
wordSpacing: "normal",
wordBreak: "normal" as const,
wordWrap: "normal" as const,
lineHeight: "1.6",
tabSize: 4,
hyphens: "none" as const,
},
'pre[class*="language-"]': {
color: "#1a1a1a",
background: "#f0ece4",
fontFamily:
"SF Mono, Monaco, Cascadia Code, Roboto Mono, Consolas, Courier New, monospace",
fontSize: "14px",
textAlign: "left" as const,
whiteSpace: "pre" as const,
wordSpacing: "normal",
wordBreak: "normal" as const,
wordWrap: "normal" as const,
lineHeight: "1.6",
tabSize: 4,
hyphens: "none" as const,
padding: "1.5em",
margin: "1.5em 0",
overflow: "auto" as const,
borderRadius: "8px",
},
comment: { color: "#7a7a7a", fontStyle: "italic" },
prolog: { color: "#7a7a7a" },
doctype: { color: "#7a7a7a" },
cdata: { color: "#7a7a7a" },
punctuation: { color: "#1a1a1a" },
property: { color: "#8b7355" },
tag: { color: "#8b5a2b" },
boolean: { color: "#8b5a2b" },
number: { color: "#8b5a2b" },
constant: { color: "#8b5a2b" },
symbol: { color: "#a67c52" },
deleted: { color: "#b31d28" },
selector: { color: "#6b8e23" },
"attr-name": { color: "#8b7355" },
string: { color: "#6b8e23" },
char: { color: "#6b8e23" },
builtin: { color: "#8b5a2b" },
inserted: { color: "#6b8e23" },
operator: { color: "#a67c52" },
entity: { color: "#8b7355" },
url: { color: "#8b7355", textDecoration: "underline" },
variable: { color: "#a67c52" },
atrule: { color: "#8b5a2b" },
"attr-value": { color: "#6b8e23" },
function: { color: "#8b7355" },
"function-variable": { color: "#8b7355" },
keyword: { color: "#8b5a2b" },
regex: { color: "#6b8e23" },
important: { color: "#8b5a2b", fontWeight: "bold" },
bold: { fontWeight: "bold" },
italic: { fontStyle: "italic" },
namespace: { opacity: 0.7 },
"class-name": { color: "#8b7355" },
parameter: { color: "#1a1a1a" },
decorator: { color: "#8b7355" },
};
interface BlogPostProps {
content: string;
}
// Generate slug from heading text for anchor links
function generateSlug(text: string): string {
return text
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, "")
.replace(/\s+/g, "-")
.replace(/-+/g, "-")
.trim();
}
// Extract text content from React children
function getTextContent(children: React.ReactNode): string {
if (typeof children === "string") return children;
if (Array.isArray(children)) {
return children.map(getTextContent).join("");
}
if (children && typeof children === "object" && "props" in children) {
return getTextContent((children as React.ReactElement).props.children);
}
return "";
}
export default function BlogPost({ content }: BlogPostProps) {
const { theme } = useTheme();
const getCodeTheme = () => {
switch (theme) {
case "dark":
return cursorDarkTheme;
case "light":
return cursorLightTheme;
case "tan":
return cursorTanTheme;
default:
return cursorDarkTheme;
}
};
return (
<article className="blog-post-content">
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkBreaks]}
rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema]]}
components={{
code({ className, children, ...props }) {
const match = /language-(\w+)/.exec(className || "");
const isInline = !match && !className;
if (isInline) {
return (
<code className="inline-code" {...props}>
{children}
</code>
);
}
const codeString = String(children).replace(/\n$/, "");
return (
<div className="code-block-wrapper">
{match && <span className="code-language">{match[1]}</span>}
<CodeCopyButton code={codeString} />
<SyntaxHighlighter
style={getCodeTheme()}
language={match ? match[1] : "text"}
PreTag="div"
>
{codeString}
</SyntaxHighlighter>
</div>
);
},
img({ src, alt }) {
return (
<span className="blog-image-wrapper">
<img
src={src}
alt={alt || ""}
className="blog-image"
loading="lazy"
/>
{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">
{children}
</h1>
);
},
h2({ children }) {
const id = generateSlug(getTextContent(children));
return (
<h2 id={id} className="blog-h2">
{children}
</h2>
);
},
h3({ children }) {
const id = generateSlug(getTextContent(children));
return (
<h3 id={id} className="blog-h3">
{children}
</h3>
);
},
h4({ children }) {
const id = generateSlug(getTextContent(children));
return (
<h4 id={id} className="blog-h4">
{children}
</h4>
);
},
h5({ children }) {
const id = generateSlug(getTextContent(children));
return (
<h5 id={id} className="blog-h5">
{children}
</h5>
);
},
h6({ children }) {
const id = generateSlug(getTextContent(children));
return (
<h6 id={id} className="blog-h6">
{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>;
},
}}
>
{content}
</ReactMarkdown>
</article>
);
}