feat: Hyperlinkable Headings + blog heading fixes (#513)

This commit is contained in:
Kalidou Diagne
2025-07-18 14:24:02 +02:00
committed by GitHub
parent e005cea58b
commit ab88aa3e8f
4 changed files with 250 additions and 103 deletions

View File

@@ -12,6 +12,7 @@ import { cn, getBackgroundImage } from "@/lib/utils"
import { Metadata } from "next"
import Link from "next/link"
import { notFound } from "next/navigation"
import { BlogHeader } from "../sections/BlogHeader"
export const dynamic = "force-dynamic"
@@ -101,79 +102,7 @@ export default function BlogArticle({ params }: any) {
: undefined,
}}
/>
<div className="flex items-start justify-center z-0 bg-cover border-tuatara-300 border-b w-full dark:border-anakiwa-800">
<AppContent className="flex flex-col gap-8 py-10 max-w-[978px]">
<Label.PageTitle
label={post?.title}
className={cn(imageAsCover && "text-white")}
/>
{post?.date || post?.tldr ? (
<div className="flex flex-col gap-2">
{post?.date && (
<div
className={blogArticleCardTagCardVariants({
variant: "secondary",
})}
>
{new Date(post?.date).toLocaleDateString("en-US", {
month: "long",
day: "numeric",
year: "numeric",
})}
</div>
)}
{post?.canonical && (
<div
className={cn(
"text-sm italic mt-1",
imageAsCover ? "text-white" : "text-gray-500"
)}
>
This post was originally posted in{" "}
<a
href={post.canonical}
target="_blank"
rel="noopener noreferrer canonical"
className={cn(
"text-primary hover:underline",
imageAsCover ? "text-white" : "text-gray-500"
)}
>
{new URL(post.canonical).hostname.replace(/^www\./, "")}
</a>
</div>
)}
{post?.tldr && (
<Markdown darkMode={imageAsCover}>{post?.tldr}</Markdown>
)}
</div>
) : null}
{(post?.tags ?? [])?.length > 0 && (
<div className="flex flex-col gap-2">
<span
className={cn(
"text-sm italic",
imageAsCover ? "text-white" : "text-primary"
)}
>
Tags:
</span>
<div className="flex flex-wrap gap-2">
{post?.tags?.map((tag) => (
<Link key={tag.id} href={`/blog/tags/${tag.id}`}>
<Button
size="xs"
variant={imageAsCover ? "secondary" : "default"}
>
{tag.name}
</Button>
</Link>
))}
</div>
</div>
)}
</AppContent>
</div>
<BlogHeader post={post} imageAsCover={imageAsCover} />
</div>
<div className="pt-10 md:pt-16 pb-32">
<BlogContent post={post} isNewsletter={isNewsletter} />

View File

@@ -0,0 +1,114 @@
"use client"
import { blogArticleCardTagCardVariants } from "@/components/blog/blog-article-card"
import { AppContent } from "@/components/ui/app-content"
import { Button } from "@/components/ui/button"
import { Label } from "@/components/ui/label"
import { Markdown } from "@/components/ui/markdown"
import { cn } from "@/lib/utils"
import Link from "next/link"
interface BlogHeaderProps {
post: any
imageAsCover: boolean
}
export const BlogHeader = ({ post, imageAsCover }: BlogHeaderProps) => {
const authors =
(post?.authors ?? [])?.length > 0 ? post.authors?.join(", ") : null
return (
<div className="flex items-start justify-center z-0 bg-cover border-tuatara-300 border-b w-full dark:border-anakiwa-800">
<AppContent className="flex flex-col gap-8 py-10 max-w-[978px]">
<div className="flex flex-col gap-2">
<Label.PageTitle
label={post?.title}
className={cn(imageAsCover && "text-white")}
/>
{authors && (
<span className={cn("text-sm font-sans text-tuatara-300")}>
{authors}
</span>
)}
</div>
{post?.date || post?.tldr ? (
<div className="flex flex-col gap-2">
{post?.date && (
<div
className={blogArticleCardTagCardVariants({
variant: "secondary",
})}
>
{new Date(post?.date).toLocaleDateString("en-US", {
month: "long",
day: "numeric",
year: "numeric",
})}
</div>
)}
{post?.canonical && (
<div
className={cn(
"text-sm italic mt-1",
imageAsCover ? "text-white" : "text-gray-500"
)}
>
This post was originally posted in{" "}
<a
href={post.canonical}
target="_blank"
rel="noopener noreferrer canonical"
className={cn(
"text-primary hover:underline",
imageAsCover ? "text-white" : "text-gray-500"
)}
>
{new URL(post.canonical).hostname.replace(/^www\./, "")}
</a>
</div>
)}
{post?.tldr && (
<Markdown
darkMode={imageAsCover}
components={{
p: ({ children }) => (
<p className="text-white dark:text-tuatara-200 font-sans text-lg font-normal">
{children}
</p>
),
}}
>
{post?.tldr}
</Markdown>
)}
</div>
) : null}
{(post?.tags ?? [])?.length > 0 && (
<div className="flex flex-col gap-2">
<span
className={cn(
"text-sm italic",
imageAsCover ? "text-white" : "text-primary"
)}
>
Tags:
</span>
<div className="flex flex-wrap gap-2">
{post?.tags?.map((tag: any) => (
<Link key={tag.id} href={`/blog/tags/${tag.id}`}>
<Button
size="xs"
variant={imageAsCover ? "secondary" : "default"}
>
{tag.name}
</Button>
</Link>
))}
</div>
</div>
)}
</AppContent>
</div>
)
}

View File

@@ -542,6 +542,39 @@ export const Icons = {
/>
</svg>
),
copied: (props: LucideProps) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<path d="M20 6L9 17l-5-5" />
</svg>
),
copy: (props: LucideProps) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
</svg>
),
mirror: (props: LucideProps) => (
<svg
xmlns="http://www.w3.org/2000/svg"

View File

@@ -22,6 +22,7 @@ import "prismjs/components/prism-yaml"
import "prismjs/components/prism-python"
import "prismjs/components/prism-rust"
import "prismjs/components/prism-solidity"
import { Icons } from "../icons"
const SCROLL_OFFSET = 150
@@ -452,6 +453,85 @@ const CodeBlock = ({
)
}
const HeadingLink = ({
level,
children,
}: {
level: number
children: React.ReactNode
}) => {
const [copied, setCopied] = React.useState(false)
const [showLink, setShowLink] = React.useState(false)
const id = React.useMemo(() => {
if (typeof children === "string") {
return generateSectionId(children)
}
const text = React.Children.toArray(children)
.map((child) => {
if (typeof child === "string") return child
if (
React.isValidElement(child) &&
typeof child.props?.children === "string"
) {
return child.props.children
}
return ""
})
.join("")
return generateSectionId(text)
}, [children])
const copyToClipboard = React.useCallback(() => {
const url = new URL(window.location.href)
url.hash = id
navigator.clipboard.writeText(url.toString())
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}, [id])
const headingClasses = React.useMemo(() => {
switch (level) {
case 1:
return "text-4xl md:text-5xl font-bold"
case 2:
return "text-4xl"
case 3:
return "text-3xl"
case 4:
return "text-xl"
case 5:
return "text-lg font-bold"
default:
return "text-md font-bold"
}
}, [level])
const Component = React.createElement(
`h${level}` as keyof JSX.IntrinsicElements,
{
id,
className: `group relative flex items-center gap-2 text-primary ${headingClasses}`,
onMouseEnter: () => setShowLink(true),
onMouseLeave: () => setShowLink(false),
},
<>
{children}
<button
onClick={copyToClipboard}
className={`ml-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200 ${
copied ? "text-green-500" : "text-tuatara-400 hover:text-tuatara-600"
}`}
aria-label="Copy link to section"
>
{copied ? <Icons.copied /> : <Icons.copy />}
</button>
</>
)
return Component
}
const REACT_MARKDOWN_CONFIG = (darkMode: boolean): CustomComponents => ({
a: ({ href, children }) => {
if (href?.startsWith("#")) {
@@ -485,36 +565,12 @@ const REACT_MARKDOWN_CONFIG = (darkMode: boolean): CustomComponents => ({
</a>
)
},
h1: ({ ...props }) =>
createMarkdownElement("h1", {
className: "text-primary text-4xl md:text-5xl font-bold",
...props,
}),
h2: ({ ...props }) =>
createMarkdownElement("h2", {
className: "text-primary text-4xl",
...props,
}),
h3: ({ ...props }) =>
createMarkdownElement("h3", {
className: "text-primary text-3xl",
...props,
}),
h4: ({ ...props }) =>
createMarkdownElement("h4", {
className: "text-primary text-xl",
...props,
}),
h5: ({ ...props }) =>
createMarkdownElement("h5", {
className: "text-primary text-lg font-bold",
...props,
}),
h6: ({ ...props }) =>
createMarkdownElement("h6", {
className: "text-primary text-md font-bold",
...props,
}),
h1: ({ children }) => <HeadingLink level={1}>{children}</HeadingLink>,
h2: ({ children }) => <HeadingLink level={2}>{children}</HeadingLink>,
h3: ({ children }) => <HeadingLink level={3}>{children}</HeadingLink>,
h4: ({ children }) => <HeadingLink level={4}>{children}</HeadingLink>,
h5: ({ children }) => <HeadingLink level={5}>{children}</HeadingLink>,
h6: ({ children }) => <HeadingLink level={6}>{children}</HeadingLink>,
code: ({ node, inline, className, children, ...props }) => {
const match = /language-(\w+)/.exec(className || "")
return !inline ? (
@@ -841,6 +897,21 @@ export const Markdown = ({
}: MarkdownProps) => {
const [content, setContent] = React.useState<React.ReactNode[]>([])
React.useEffect(() => {
const timer = setTimeout(() => {
const hash = window.location.hash
if (hash) {
const id = hash.slice(1) // Remove the # from the hash
const element = document.getElementById(id)
if (element) {
scrollToElementWithOffset(element)
}
}
}, 100)
return () => clearTimeout(timer)
}, [content])
React.useEffect(() => {
if (!children) {
setContent([])