feat: 添加代码高亮支持,优化Markdown渲染,新增滚动到顶部组件

This commit is contained in:
2025-07-28 11:04:13 +08:00
parent 06e270545a
commit 812962eb84
8 changed files with 317 additions and 8 deletions

View File

@ -5,6 +5,7 @@ import { DeviceProvider } from "@/contexts/device-context";
import { NextIntlClientProvider } from 'next-intl';
import config from "@/config";
import { getUserLocales } from "@/i18n/request";
import { Toaster } from "@/components/ui/sonner"
const geistSans = Geist({
variable: "--font-geist-sans",
@ -31,6 +32,7 @@ export default async function RootLayout({
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<Toaster richColors/>
<DeviceProvider>
<NextIntlClientProvider>{children}</NextIntlClientProvider>
</DeviceProvider>

View File

@ -2,6 +2,8 @@ import { Suspense } from "react";
import type { Post } from "@/models/post";
import { Calendar, Clock, FileText, Flame, Heart, MessageCircle, PenLine, SquarePen } from "lucide-react";
import { MDXRemote } from "next-mdx-remote-client/rsc";
import ScrollToTop from "@/components/scroll-to-top.client";
import { RenderMarkdown } from "@/components/markdown";
function PostMeta({ post }: { post: Post }) {
return (
@ -19,7 +21,7 @@ function PostMeta({ post }: { post: Post }) {
{/* 阅读时间 */}
<span className="flex items-center gap-1">
<Clock className="w-4 h-4" />
{post.content.length / 100 || 1}
{Math.ceil(post.content.length / 400 || 1)}
</span>
{/* 发布时间 */}
<span className="flex items-center gap-1">
@ -90,19 +92,32 @@ function PostHeader({ post }: { post: Post }) {
}
async function PostContent({ post }: { post: Post }) {
const markdownClass =
"prose prose-lg max-w-none dark:prose-invert " +
// h1-h6
"[&_h1]:scroll-m-20 [&_h1]:text-4xl [&_h1]:font-extrabold [&_h1]:tracking-tight [&_h1]:text-balance [&_h1]:mt-10 [&_h1]:mb-6 " +
"[&_h2]:scroll-m-20 [&_h2]:border-b [&_h2]:pb-2 [&_h2]:text-3xl [&_h2]:font-semibold [&_h2]:tracking-tight [&_h2]:first:mt-0 [&_h2]:mt-8 [&_h2]:mb-4 " +
"[&_h3]:scroll-m-20 [&_h3]:text-2xl [&_h3]:font-semibold [&_h3]:tracking-tight [&_h3]:mt-6 [&_h3]:mb-3 " +
"[&_h4]:scroll-m-20 [&_h4]:text-xl [&_h4]:font-semibold [&_h4]:tracking-tight [&_h4]:mt-5 [&_h4]:mb-2 " +
// p
"[&_p]:leading-7 [&_p]:mt-4 [&_p]:mb-4 " +
// blockquote
"[&_blockquote]:border-l-4 [&_blockquote]:border-blue-400 [&_blockquote]:pl-4 [&_blockquote]:italic [&_blockquote]:my-6 [&_blockquote]:py-2 " +
// code
"[&_code]:bg-gray-100 [&_code]:dark:bg-gray-800 [&_code]:rounded [&_code]:px-1 [&_code]:py-0.5 [&_code]:text-sm [&_code]:font-mono " +
// a
"[&_a]:text-blue-600 [&_a]:hover:underline";
return (
<div className="py-12">
<div className="prose prose-lg max-w-none dark:prose-invert">
{post.type === "html" && (
<div
className="prose prose-lg max-w-none dark:prose-invert [&_h1]:text-5xl [&_h2]:text-4xl [&_h3]:text-3xl [&_h4]:text-2xl [&_h5]:text-xl [&_h6]:text-lg [&_p]:text-xl [&_p]:my-6 [&_ul]:my-6 [&_ol]:my-6 [&_pre]:my-8 [&_blockquote]:my-8"
className={`${markdownClass}`}
dangerouslySetInnerHTML={{ __html: post.content }}
/>
)}
{post.type === "markdown" && (
<Suspense>
<MDXRemote
source={post.content}
/>
<RenderMarkdown source={post.content} />
</Suspense>
)}
{post.type === "text" && (
@ -118,10 +133,9 @@ async function PostContent({ post }: { post: Post }) {
async function BlogPost({ post }: { post: Post }) {
return (
<div className="">
<ScrollToTop />
<PostHeader post={post} />
<div className="">
<PostContent post={post} />
</div>
</div>
);
}

View File

@ -0,0 +1,88 @@
'use client'
import React from "react";
import { toast } from "sonner";
function extractText(node: any): string {
if (typeof node === "string") return node;
if (Array.isArray(node)) return node.map(extractText).join("");
if (node && typeof node === "object" && "props" in node) {
return extractText(node.props.children);
}
return "";
}
export default function CodeBlock(props: React.ComponentPropsWithoutRef<"pre">) {
let className: string | undefined = undefined;
if (
props.children &&
typeof props.children === "object" &&
"props" in props.children &&
props.children &&
typeof (props.children as any).props === "object" &&
(props.children as any).props.className
) {
className = (props.children as any).props.className as string | undefined;
}
let language = "";
if (className) {
const match = className.match(/language-(\w+)/);
if (match) {
language = match[1];
}
}
let codeContent = "";
if (
props.children &&
typeof props.children === "object" &&
"props" in props.children &&
(props.children as any).props
) {
codeContent = extractText((props.children as any).props.children);
}
function handleCopy(e: React.MouseEvent<HTMLButtonElement>) {
if (typeof window !== "undefined" && window.navigator?.clipboard) {
window.navigator.clipboard.writeText(codeContent);
} else {
const textarea = document.createElement("textarea");
textarea.value = codeContent;
document.body.appendChild(textarea);
textarea.select();
document.execCommand("copy");
document.body.removeChild(textarea);
}
toast.success("已经复制", {
description: "代码已复制到剪贴板",
});
}
return (
<div className="relative my-6 rounded-lg overflow-hidden bg-[#f5f5f7] dark:bg-[#23272f] border border-gray-200 dark:border-gray-700 shadow-sm group">
<div className="flex items-center h-8 px-3 bg-[#e5e7eb] dark:bg-[#23272f] border-b border-gray-200 dark:border-gray-700 relative">
<span className="w-3 h-3 rounded-full bg-red-400 mr-2" />
<span className="w-3 h-3 rounded-full bg-yellow-400 mr-2" />
<span className="w-3 h-3 rounded-full bg-green-400" />
{language && (
<span className="absolute left-1/2 -translate-x-1/2 text-xs text-gray-500 dark:text-gray-400 font-mono">
{language}
</span>
)}
<div className="absolute right-3 top-1/2 -translate-y-1/2 flex space-x-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button
type="button"
className="px-2 py-1 rounded text-xs bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-200 border border-gray-300 dark:border-gray-600"
title="复制代码"
onClick={handleCopy}
tabIndex={-1}
>
</button>
</div>
</div>
<pre
className="overflow-x-auto bg-transparent text-sm text-gray-800 dark:text-gray-100"
{...props}
/>
</div>
);
}

View File

@ -0,0 +1,82 @@
import { MDXRemote, MDXRemoteProps } from "next-mdx-remote-client/rsc";
import { Suspense } from "react";
import rehypeHighlight from "rehype-highlight";
import "highlight.js/styles/github.css"; // 你可以换成喜欢的主题
import CodeBlock from "@/components/markdown-codeblock";
export const markdownComponents = {
h1: (props: React.ComponentPropsWithoutRef<"h1">) => (
<h1
className="scroll-m-20 text-4xl font-extrabold tracking-tight text-balance mt-10 mb-6"
{...props}
/>
),
h2: (props: React.ComponentPropsWithoutRef<"h2">) => (
<h2
className="scroll-m-20 border-b pb-2 text-3xl font-semibold tracking-tight first:mt-0 mt-8 mb-4"
{...props}
/>
),
h3: (props: React.ComponentPropsWithoutRef<"h3">) => (
<h3
className="scroll-m-20 text-2xl font-semibold tracking-tight mt-6 mb-3"
{...props}
/>
),
h4: (props: React.ComponentPropsWithoutRef<"h4">) => (
<h4
className="scroll-m-20 text-xl font-semibold tracking-tight mt-5 mb-2"
{...props}
/>
),
p: (props: React.ComponentPropsWithoutRef<"p">) => (
<p
className="leading-7 mt-4 mb-4"
{...props}
/>
),
blockquote: (props: React.ComponentPropsWithoutRef<"blockquote">) => (
<blockquote
className="border-l-4 border-blue-400 pl-4 italic my-6 py-2"
{...props}
/>
),
code: (props: React.ComponentPropsWithoutRef<"code">) => (
<code
className="bg-gray-100 dark:bg-gray-800 rounded px-1 py-0.5 text-sm font-mono"
{...props}
/>
),
pre: ({ children, ...props }: React.ComponentPropsWithoutRef<"pre">) => (
<CodeBlock {...props}>{children}</CodeBlock>
),
a: (props: React.ComponentPropsWithoutRef<"a">) => (
<a
className="text-blue-600 hover:underline"
{...props}
/>
),
};
export function RenderMarkdown(props: Omit<MDXRemoteProps, "components">) {
return (
<Suspense fallback={<div>...</div>}>
<MDXRemote {...props}
components={markdownComponents}
options={{
mdxOptions: {
rehypePlugins: [[rehypeHighlight, { ignoreMissing: true }]],
}
}} />
</Suspense>
);
}
export function RenderMarkdownWithComponents(props: MDXRemoteProps) {
return (
<Suspense fallback={<div>...</div>}>
<MDXRemote {...props} components={markdownComponents} />
</Suspense>
);
}

View File

@ -0,0 +1,9 @@
'use client'
import { useEffect } from "react";
export default function ScrollToTop() {
useEffect(() => {
window.scrollTo(0, 0);
}, []);
return null;
}

View File

@ -0,0 +1,25 @@
"use client"
import { useTheme } from "next-themes"
import { Toaster as Sonner, ToasterProps } from "sonner"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
} as React.CSSProperties
}
{...props}
/>
)
}
export { Toaster }