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

@ -20,13 +20,17 @@
"deepmerge": "^4.3.1",
"field-conv": "^1.0.9",
"framer-motion": "^12.23.9",
"highlight.js": "^11.11.1",
"lucide-react": "^0.525.0",
"next": "15.4.1",
"next-intl": "^4.3.4",
"next-mdx-remote-client": "^2.1.3",
"next-themes": "^0.4.6",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-icons": "^5.5.0",
"rehype-highlight": "^7.0.2",
"sonner": "^2.0.6",
"tailwind-merge": "^3.3.1"
},
"devDependencies": {

85
web/pnpm-lock.yaml generated
View File

@ -41,6 +41,9 @@ importers:
framer-motion:
specifier: ^12.23.9
version: 12.23.9(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
highlight.js:
specifier: ^11.11.1
version: 11.11.1
lucide-react:
specifier: ^0.525.0
version: 0.525.0(react@19.1.0)
@ -53,6 +56,9 @@ importers:
next-mdx-remote-client:
specifier: ^2.1.3
version: 2.1.3(@types/react@19.1.8)(acorn@8.15.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(unified@11.0.5)
next-themes:
specifier: ^0.4.6
version: 0.4.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
react:
specifier: 19.1.0
version: 19.1.0
@ -62,6 +68,12 @@ importers:
react-icons:
specifier: ^5.5.0
version: 5.5.0(react@19.1.0)
rehype-highlight:
specifier: ^7.0.2
version: 7.0.2
sonner:
specifier: ^2.0.6
version: 2.0.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
tailwind-merge:
specifier: ^3.3.1
version: 3.3.1
@ -1610,15 +1622,25 @@ packages:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'}
hast-util-is-element@3.0.0:
resolution: {integrity: sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==}
hast-util-to-estree@3.1.3:
resolution: {integrity: sha512-48+B/rJWAp0jamNbAAf9M7Uf//UVqAoMmgXhBdxTDJLGKY+LRnZ99qcG+Qjl5HfMpYNzS5v4EAwVEF34LeAj7w==}
hast-util-to-jsx-runtime@2.3.6:
resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==}
hast-util-to-text@4.0.2:
resolution: {integrity: sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==}
hast-util-whitespace@3.0.0:
resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==}
highlight.js@11.11.1:
resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==}
engines: {node: '>=12.0.0'}
ignore@5.3.2:
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
engines: {node: '>= 4'}
@ -1897,6 +1919,9 @@ packages:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true
lowlight@3.3.0:
resolution: {integrity: sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==}
lucide-react@0.525.0:
resolution: {integrity: sha512-Tm1txJ2OkymCGkvwoHt33Y2JpN5xucVq1slHcgE6Lk0WjDfjgKWor5CdVER8U6DvcfMwh4M8XxmpTiyzfmfDYQ==}
peerDependencies:
@ -2106,6 +2131,12 @@ packages:
react: ^19.1.0
react-dom: ^19.1.0
next-themes@0.4.6:
resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==}
peerDependencies:
react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
next@15.4.1:
resolution: {integrity: sha512-eNKB1q8C7o9zXF8+jgJs2CzSLIU3T6bQtX6DcTnCq1sIR1CJ0GlSyRs1BubQi3/JgCnr9Vr+rS5mOMI38FFyQw==}
engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0}
@ -2303,6 +2334,9 @@ packages:
resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==}
engines: {node: '>= 0.4'}
rehype-highlight@7.0.2:
resolution: {integrity: sha512-k158pK7wdC2qL3M5NcZROZ2tR/l7zOzjxXd5VGdcfIyoijjQqpHd3JKtYSBDpDZ38UI2WJWuFAtkMDxmx5kstA==}
rehype-recma@1.0.0:
resolution: {integrity: sha512-lqA4rGUf1JmacCNWWZx0Wv1dHqMwxzsDWYMTowuplHF3xH0N/MmrZ/G3BDZnzAkRmxDadujCjaKM2hqYdCBOGw==}
@ -2414,6 +2448,12 @@ packages:
simple-swizzle@0.2.2:
resolution: {integrity: sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==}
sonner@2.0.6:
resolution: {integrity: sha512-yHFhk8T/DK3YxjFQXIrcHT1rGEeTLliVzWbO0xN8GberVun2RiBnxAjXAYpZrqwEVHBG9asI/Li8TAAhN9m59Q==}
peerDependencies:
react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
@ -2578,6 +2618,9 @@ packages:
unified@11.0.5:
resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==}
unist-util-find-after@5.0.0:
resolution: {integrity: sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==}
unist-util-is@6.0.0:
resolution: {integrity: sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw==}
@ -4313,6 +4356,10 @@ snapshots:
dependencies:
function-bind: 1.1.2
hast-util-is-element@3.0.0:
dependencies:
'@types/hast': 3.0.4
hast-util-to-estree@3.1.3:
dependencies:
'@types/estree': 1.0.8
@ -4354,10 +4401,19 @@ snapshots:
transitivePeerDependencies:
- supports-color
hast-util-to-text@4.0.2:
dependencies:
'@types/hast': 3.0.4
'@types/unist': 3.0.3
hast-util-is-element: 3.0.0
unist-util-find-after: 5.0.0
hast-util-whitespace@3.0.0:
dependencies:
'@types/hast': 3.0.4
highlight.js@11.11.1: {}
ignore@5.3.2: {}
ignore@7.0.5: {}
@ -4621,6 +4677,12 @@ snapshots:
dependencies:
js-tokens: 4.0.0
lowlight@3.3.0:
dependencies:
'@types/hast': 3.0.4
devlop: 1.1.0
highlight.js: 11.11.1
lucide-react@0.525.0(react@19.1.0):
dependencies:
react: 19.1.0
@ -5012,6 +5074,11 @@ snapshots:
- supports-color
- unified
next-themes@0.4.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
dependencies:
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
next@15.4.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
dependencies:
'@next/env': 15.4.1
@ -5246,6 +5313,14 @@ snapshots:
gopd: 1.2.0
set-function-name: 2.0.2
rehype-highlight@7.0.2:
dependencies:
'@types/hast': 3.0.4
hast-util-to-text: 4.0.2
lowlight: 3.3.0
unist-util-visit: 5.0.0
vfile: 6.0.3
rehype-recma@1.0.0:
dependencies:
'@types/estree': 1.0.8
@ -5429,6 +5504,11 @@ snapshots:
is-arrayish: 0.3.2
optional: true
sonner@2.0.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
dependencies:
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
source-map-js@1.2.1: {}
source-map@0.7.6: {}
@ -5625,6 +5705,11 @@ snapshots:
trough: 2.2.0
vfile: 6.0.3
unist-util-find-after@5.0.0:
dependencies:
'@types/unist': 3.0.3
unist-util-is: 6.0.0
unist-util-is@6.0.0:
dependencies:
'@types/unist': 3.0.3

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 }