mirror of
https://github.com/snowykami/neo-blog.git
synced 2025-09-05 16:56:22 +00:00
feat: 添加代码高亮支持,优化Markdown渲染,新增滚动到顶部组件
This commit is contained in:
@ -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>
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
88
web/src/components/markdown-codeblock.tsx
Normal file
88
web/src/components/markdown-codeblock.tsx
Normal 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>
|
||||
);
|
||||
}
|
82
web/src/components/markdown.tsx
Normal file
82
web/src/components/markdown.tsx
Normal 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>
|
||||
);
|
||||
}
|
9
web/src/components/scroll-to-top.client.tsx
Normal file
9
web/src/components/scroll-to-top.client.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
'use client'
|
||||
import { useEffect } from "react";
|
||||
|
||||
export default function ScrollToTop() {
|
||||
useEffect(() => {
|
||||
window.scrollTo(0, 0);
|
||||
}, []);
|
||||
return null;
|
||||
}
|
25
web/src/components/ui/sonner.tsx
Normal file
25
web/src/components/ui/sonner.tsx
Normal 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 }
|
Reference in New Issue
Block a user