310 lines
7.5 KiB
TypeScript
310 lines
7.5 KiB
TypeScript
"use client";
|
|
|
|
import React, { createContext, useContext } from "react";
|
|
import ReactMarkdown from "react-markdown";
|
|
import type { Components } from "react-markdown";
|
|
import type { Element } from "hast";
|
|
|
|
// Lets the `code` renderer know it's inside a `pre` block vs. inline.
|
|
// React renders parent-to-child, so the Provider in `pre` is active
|
|
// when `code` renders inside it.
|
|
const InsidePreContext = createContext(false);
|
|
|
|
function GoldDot() {
|
|
return (
|
|
<span
|
|
style={{
|
|
width: 8,
|
|
height: 8,
|
|
borderRadius: "50%",
|
|
background: "linear-gradient(135deg, #facc15, #f97316)",
|
|
boxShadow: "0 0 8px rgba(250, 204, 21, 0.7)",
|
|
flexShrink: 0,
|
|
display: "inline-block",
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function BlueDot() {
|
|
return (
|
|
<span
|
|
style={{
|
|
width: 8,
|
|
height: 8,
|
|
borderRadius: "50%",
|
|
background: "linear-gradient(135deg, #60a5fa, #34d399)",
|
|
boxShadow: "0 0 8px rgba(96, 165, 250, 0.6)",
|
|
flexShrink: 0,
|
|
display: "inline-block",
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function KeyBadge({ children }: { children: React.ReactNode }) {
|
|
return (
|
|
<span
|
|
style={{
|
|
background: "rgba(250, 204, 21, 0.12)",
|
|
border: "1px solid rgba(250, 204, 21, 0.35)",
|
|
borderRadius: 6,
|
|
padding: "1px 10px",
|
|
color: "#fde68a",
|
|
fontWeight: 600,
|
|
fontSize: "0.875rem",
|
|
whiteSpace: "nowrap",
|
|
}}
|
|
>
|
|
{children}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
// Extracts plain text from a HAST element's text children,
|
|
// stripping a trailing colon (e.g. "Backend:" → "Backend").
|
|
function extractHastText(node: Element): string {
|
|
return (node.children ?? [])
|
|
.filter((c): c is { type: "text"; value: string } => c.type === "text")
|
|
.map((c) => c.value)
|
|
.join("")
|
|
.replace(/:$/, "")
|
|
.trim();
|
|
}
|
|
|
|
function CodeRenderer({ children }: { children?: React.ReactNode }) {
|
|
const insidePre = useContext(InsidePreContext);
|
|
|
|
if (insidePre) {
|
|
return (
|
|
<code
|
|
style={{
|
|
fontFamily: "var(--font-mono, 'Geist Mono', monospace)",
|
|
fontSize: "0.9rem",
|
|
color: "#e2e8f0",
|
|
lineHeight: 1.65,
|
|
}}
|
|
>
|
|
{children}
|
|
</code>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<code
|
|
style={{
|
|
background: "rgba(250, 204, 21, 0.1)",
|
|
border: "1px solid rgba(250, 204, 21, 0.3)",
|
|
borderRadius: 5,
|
|
padding: "1px 7px",
|
|
color: "#fde68a",
|
|
fontFamily: "var(--font-mono, 'Geist Mono', monospace)",
|
|
fontSize: "0.875em",
|
|
}}
|
|
>
|
|
{children}
|
|
</code>
|
|
);
|
|
}
|
|
|
|
const components: Components = {
|
|
h1: ({ children }) => (
|
|
<h1
|
|
style={{
|
|
fontSize: "2.4rem",
|
|
fontWeight: 800,
|
|
margin: "0 0 1.5rem 0",
|
|
background: "linear-gradient(90deg, #facc15, #60a5fa)",
|
|
WebkitBackgroundClip: "text",
|
|
WebkitTextFillColor: "transparent",
|
|
backgroundClip: "text",
|
|
letterSpacing: "-0.02em",
|
|
}}
|
|
>
|
|
{children}
|
|
</h1>
|
|
),
|
|
|
|
h2: ({ children }) => (
|
|
<div style={{ margin: "1.75rem 0 0.75rem 0" }}>
|
|
<h2
|
|
style={{
|
|
fontSize: "1.25rem",
|
|
fontWeight: 700,
|
|
margin: 0,
|
|
background: "linear-gradient(90deg, #facc15, #60a5fa, #34d399)",
|
|
WebkitBackgroundClip: "text",
|
|
WebkitTextFillColor: "transparent",
|
|
backgroundClip: "text",
|
|
letterSpacing: "0.02em",
|
|
}}
|
|
>
|
|
{children}
|
|
</h2>
|
|
<span
|
|
style={{
|
|
display: "block",
|
|
height: 2,
|
|
marginTop: 6,
|
|
background: "linear-gradient(90deg, #facc15, #60a5fa, transparent)",
|
|
borderRadius: 2,
|
|
}}
|
|
/>
|
|
</div>
|
|
),
|
|
|
|
h3: ({ children }) => (
|
|
<div style={{ margin: "1.25rem 0 0.5rem 0" }}>
|
|
<h3
|
|
style={{
|
|
fontSize: "1.1rem",
|
|
fontWeight: 600,
|
|
margin: 0,
|
|
background: "linear-gradient(90deg, #facc15, #60a5fa)",
|
|
WebkitBackgroundClip: "text",
|
|
WebkitTextFillColor: "transparent",
|
|
backgroundClip: "text",
|
|
}}
|
|
>
|
|
{children}
|
|
</h3>
|
|
<span
|
|
style={{
|
|
display: "block",
|
|
height: 1,
|
|
marginTop: 4,
|
|
background:
|
|
"linear-gradient(90deg, rgba(250,204,21,0.5), rgba(96,165,250,0.5), transparent)",
|
|
borderRadius: 1,
|
|
}}
|
|
/>
|
|
</div>
|
|
),
|
|
|
|
p: ({ children }) => (
|
|
<p
|
|
style={{
|
|
color: "#cbd5e1",
|
|
lineHeight: 1.75,
|
|
margin: "0 0 1.25rem 0",
|
|
fontSize: "1.05rem",
|
|
}}
|
|
>
|
|
{children}
|
|
</p>
|
|
),
|
|
|
|
ul: ({ children }) => (
|
|
<ul
|
|
style={{
|
|
listStyle: "none",
|
|
padding: 0,
|
|
margin: "0 0 1.25rem 0",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
gap: 9,
|
|
}}
|
|
>
|
|
{children}
|
|
</ul>
|
|
),
|
|
|
|
li: ({ node, children }) => {
|
|
// Inspect HAST to detect the `- **Key:** value` pattern.
|
|
// HAST tagName is always 'strong' regardless of custom component overrides.
|
|
const firstHast = node?.children?.[0];
|
|
const isKeyValue =
|
|
firstHast?.type === "element" &&
|
|
(firstHast as Element).tagName === "strong";
|
|
|
|
if (isKeyValue) {
|
|
const keyText = extractHastText(firstHast as Element);
|
|
// Skip the first rendered child (the <strong>) — we replace it with KeyBadge.
|
|
const rest = React.Children.toArray(children).slice(1);
|
|
return (
|
|
<li
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 10,
|
|
fontSize: "1rem",
|
|
}}
|
|
>
|
|
<GoldDot />
|
|
<KeyBadge>{keyText}</KeyBadge>
|
|
<span style={{ color: "#94a3b8" }}>{rest}</span>
|
|
</li>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<li
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 10,
|
|
fontSize: "1rem",
|
|
color: "#e2e8f0",
|
|
}}
|
|
>
|
|
<BlueDot />
|
|
{children}
|
|
</li>
|
|
);
|
|
},
|
|
|
|
a: ({ href, children }) => (
|
|
<a
|
|
href={href}
|
|
target={href && /^https?:\/\//.test(href) ? "_blank" : undefined}
|
|
rel={href && /^https?:\/\//.test(href) ? "noopener noreferrer" : undefined}
|
|
style={{
|
|
background: "linear-gradient(90deg, #facc15, #60a5fa)",
|
|
WebkitBackgroundClip: "text",
|
|
WebkitTextFillColor: "transparent",
|
|
backgroundClip: "text",
|
|
fontWeight: 600,
|
|
textDecoration: "underline",
|
|
textDecorationColor: "rgba(250, 204, 21, 0.5)",
|
|
cursor: "pointer",
|
|
}}
|
|
>
|
|
{children}
|
|
</a>
|
|
),
|
|
|
|
strong: ({ children }) => (
|
|
<strong style={{ color: "#fde68a", fontWeight: 700 }}>{children}</strong>
|
|
),
|
|
|
|
em: ({ children }) => (
|
|
<em style={{ color: "#e2e8f0", fontStyle: "italic" }}>{children}</em>
|
|
),
|
|
|
|
// `pre` wraps `code` for fenced code blocks. The Provider lets the
|
|
// nested `code` renderer know it's in a block context (not inline).
|
|
pre: ({ children }) => (
|
|
<InsidePreContext.Provider value={true}>
|
|
<pre
|
|
style={{
|
|
background: "rgba(255, 255, 255, 0.04)",
|
|
border: "1px solid rgba(255, 255, 255, 0.12)",
|
|
borderTop: "2px solid rgba(250, 204, 21, 0.6)",
|
|
borderRadius: "0 0 10px 10px",
|
|
padding: "18px 22px",
|
|
overflowX: "auto",
|
|
margin: "0 0 1.25rem 0",
|
|
}}
|
|
>
|
|
{children}
|
|
</pre>
|
|
</InsidePreContext.Provider>
|
|
),
|
|
|
|
code: CodeRenderer,
|
|
};
|
|
|
|
export default function MarkdownContent({ content }: { content: string }) {
|
|
return <ReactMarkdown components={components}>{content}</ReactMarkdown>;
|
|
}
|