feat: add MarkdownContent component with Frutiger Aero styling
This commit is contained in:
308
components/markdown-content.tsx
Normal file
308
components/markdown-content.tsx
Normal file
@@ -0,0 +1,308 @@
|
||||
"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();
|
||||
}
|
||||
|
||||
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?.startsWith("http") ? "_blank" : undefined}
|
||||
rel={href?.startsWith("http") ? "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: ({ children }) => {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
// Inline 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>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export default function MarkdownContent({ content }: { content: string }) {
|
||||
return <ReactMarkdown components={components}>{content}</ReactMarkdown>;
|
||||
}
|
||||
Reference in New Issue
Block a user