feat(frontend): Markdown 支持 GFM、代码高亮与图片灯箱

统一 SSR/客户端渲染路径,评论与预览共用增强组件。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-16 05:41:25 +08:00
parent f3145b24ff
commit 2056d6a3f9
14 changed files with 1458 additions and 31 deletions

View File

@@ -1,10 +1,10 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import Link from "next/link"; import Link from "next/link";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import ReactMarkdown from "react-markdown";
import { ChevronRight, Megaphone } from "lucide-react"; import { ChevronRight, Megaphone } from "lucide-react";
import { fetchAnnouncementDetail } from "@/lib/api"; import { fetchAnnouncementDetail } from "@/lib/api";
import { formatDate } from "@/lib/format"; import { formatDate } from "@/lib/format";
import MarkdownBody from "@/components/MarkdownBody";
interface PageProps { interface PageProps {
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
@@ -77,9 +77,7 @@ export default async function AnnouncementPage({ params }: PageProps) {
className="mt-5 pt-5" className="mt-5 pt-5"
style={{ borderTop: "1px solid var(--line)" }} style={{ borderTop: "1px solid var(--line)" }}
> >
<div className="prose-content"> <MarkdownBody content={a.content} />
<ReactMarkdown>{a.content}</ReactMarkdown>
</div>
</div> </div>
</article> </article>

View File

@@ -1702,15 +1702,28 @@ a.j13-mention-token:hover {
border-radius: 6px; border-radius: 6px;
font-size: 0.86em; font-size: 0.86em;
} }
.prose-content pre { .prose-content pre,
background: var(--panel-2) !important; .prose-content .md-code-pre,
border: 1px solid var(--line); .prose-content .md-code-shiki-host pre {
padding: 1.05rem 1.15rem; background: transparent !important;
border-radius: var(--r-sm); border: none;
padding: 0.85rem 1.05rem;
border-radius: 0;
overflow-x: auto; overflow-x: auto;
margin: 1.15rem 0; margin: 0;
font-family: var(--font-mono);
font-size: 0.84em;
line-height: 1.65;
}
.prose-content pre code,
.prose-content .md-code-pre code,
.prose-content .md-code-shiki-host code {
background: none;
padding: 0;
border-radius: 0;
font-size: inherit;
color: inherit;
} }
.prose-content pre code { background: none; padding: 0; border-radius: 0; }
.prose-content blockquote { .prose-content blockquote {
border-left: 3px solid var(--accent); border-left: 3px solid var(--accent);
background: var(--accent-soft); background: var(--accent-soft);
@@ -1725,19 +1738,249 @@ a.j13-mention-token:hover {
text-underline-offset: 3px; text-underline-offset: 3px;
font-weight: 500; font-weight: 500;
} }
.prose-content img { .prose-content img,
.prose-content .md-content-img {
max-width: 100%; max-width: 100%;
border-radius: var(--r-sm); border-radius: var(--r-sm);
border: 1px solid var(--line); border: 1px solid var(--line);
cursor: zoom-in;
transition: opacity 0.15s ease, box-shadow 0.15s ease;
}
.prose-content .md-content-img:hover {
opacity: 0.92;
box-shadow: 0 4px 18px color-mix(in srgb, var(--ink) 12%, transparent);
}
.prose-content .md-img-fallback {
display: inline-block;
padding: 0.55rem 0.85rem;
border-radius: var(--r-sm);
border: 1px dashed var(--line);
background: var(--panel-2);
color: var(--ink-2);
font-size: 0.9em;
} }
.prose-content hr { border: none; border-top: 1px solid var(--line); margin: 2.2rem 0; } .prose-content hr { border: none; border-top: 1px solid var(--line); margin: 2.2rem 0; }
.prose-content strong { font-weight: 700; } .prose-content strong { font-weight: 700; }
.prose-content del { color: var(--ink-2); text-decoration: line-through; }
/* GFM 表格 */
.prose-content table {
width: 100%;
border-collapse: collapse;
margin: 1.2rem 0;
font-size: 0.95em;
overflow-x: auto;
display: block;
}
.prose-content thead { background: var(--panel-2); }
.prose-content th,
.prose-content td {
border: 1px solid var(--line);
padding: 0.55rem 0.85rem;
text-align: left;
vertical-align: top;
}
.prose-content th { font-weight: 700; color: var(--ink); }
.prose-content tr:nth-child(even) td { background: color-mix(in srgb, var(--tint) 55%, transparent); }
/* GFM 任务列表 */
.prose-content ul.contains-task-list { list-style: none; padding-left: 0.25rem; }
.prose-content li.task-list-item { display: flex; align-items: flex-start; gap: 0.5rem; }
.prose-content li.task-list-item input[type="checkbox"] {
margin-top: 0.4em;
accent-color: var(--accent);
pointer-events: none;
}
/* rehype-pretty-code figure 外壳 */
.prose-content figure[data-rehype-pretty-code-figure] {
margin: 1.15rem 0;
}
.prose-content figure[data-rehype-pretty-code-figure] > .md-code-block {
margin: 0;
}
/* 代码块壳:工具栏 + 折叠 */
.prose-content .md-code-block {
margin: 1.15rem 0;
border: 1px solid var(--line);
border-radius: var(--r-sm);
background: var(--panel-2);
overflow: hidden;
}
.prose-content .md-code-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.4rem 0.75rem;
border-bottom: 1px solid var(--line);
background: color-mix(in srgb, var(--panel) 70%, var(--panel-2));
}
.prose-content .md-code-lang {
font-size: 11.5px;
font-weight: 600;
letter-spacing: 0.02em;
text-transform: lowercase;
color: var(--ink-2);
font-family: var(--font-mono);
}
.prose-content .md-code-copy {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.2rem 0.55rem;
border-radius: 6px;
border: 1px solid transparent;
background: transparent;
color: var(--ink-2);
font-size: 12px;
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
}
.prose-content .md-code-copy:hover {
color: var(--ink);
background: var(--tint);
border-color: var(--line);
}
.prose-content .md-code-body {
position: relative;
overflow: hidden;
}
.prose-content .md-code-body:not(.md-code-collapsed) {
overflow-x: auto;
}
.prose-content .md-code-collapsed {
overflow: hidden;
}
.prose-content .md-code-fade {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 2.75rem;
pointer-events: none;
background: linear-gradient(to bottom, transparent, var(--panel-2));
}
.prose-content .md-code-fold {
display: flex;
width: 100%;
align-items: center;
justify-content: center;
gap: 0.35rem;
padding: 0.45rem 0.75rem;
border: none;
border-top: 1px solid var(--line);
background: color-mix(in srgb, var(--panel) 55%, var(--panel-2));
color: var(--accent);
font-size: 12.5px;
font-weight: 600;
cursor: pointer;
}
.prose-content .md-code-fold:hover {
background: var(--accent-soft);
}
/* Shiki 双主题:跟随 html.dark */
.prose-content .shiki,
.prose-content .shiki span {
color: var(--shiki-light);
background-color: transparent !important;
}
.prose-content .shiki {
background: transparent !important;
}
html.dark .prose-content .shiki,
html.dark .prose-content .shiki span {
color: var(--shiki-dark) !important;
font-style: var(--shiki-dark-font-style) !important;
font-weight: var(--shiki-dark-font-weight) !important;
text-decoration: var(--shiki-dark-text-decoration) !important;
}
/* 图箱 */
.md-lightbox {
position: fixed;
inset: 0;
z-index: 1200;
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
background: rgba(0, 0, 0, 0.82);
backdrop-filter: blur(2px);
}
.md-lightbox-figure {
margin: 0;
max-width: min(96vw, 1200px);
max-height: 90vh;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
}
.md-lightbox-img {
max-width: 100%;
max-height: calc(90vh - 3rem);
object-fit: contain;
border-radius: 8px;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.45);
user-select: none;
}
.md-lightbox-caption {
color: rgba(255, 255, 255, 0.85);
font-size: 13px;
text-align: center;
max-width: 40rem;
line-height: 1.5;
}
.md-lightbox-close,
.md-lightbox-nav {
position: absolute;
display: inline-flex;
align-items: center;
justify-content: center;
border: none;
border-radius: 999px;
background: rgba(255, 255, 255, 0.12);
color: #fff;
cursor: pointer;
transition: background 0.15s ease;
}
.md-lightbox-close:hover,
.md-lightbox-nav:hover {
background: rgba(255, 255, 255, 0.22);
}
.md-lightbox-close {
top: 1rem;
right: 1rem;
width: 42px;
height: 42px;
}
.md-lightbox-nav {
top: 50%;
transform: translateY(-50%);
width: 46px;
height: 46px;
}
.md-lightbox-prev { left: 1rem; }
.md-lightbox-next { right: 1rem; }
.md-lightbox-counter {
position: absolute;
bottom: 1.25rem;
left: 50%;
transform: translateX(-50%);
color: rgba(255, 255, 255, 0.8);
font-size: 13px;
font-variant-numeric: tabular-nums;
letter-spacing: 0.04em;
}
/* 评论区的 Markdown 紧凑排版:继承 prose-content 全部规则,仅收紧行距与间距 */ /* 评论区的 Markdown 紧凑排版:继承 prose-content 全部规则,仅收紧行距与间距 */
.prose-comment { font-size: 14.5px; line-height: 1.7; } .prose-comment { font-size: 14.5px; line-height: 1.7; }
.prose-comment p { margin: 0.35rem 0; } .prose-comment p { margin: 0.35rem 0; }
.prose-comment blockquote { margin: 0.5rem 0; padding: 0.45rem 0.9rem; } .prose-comment blockquote { margin: 0.5rem 0; padding: 0.45rem 0.9rem; }
.prose-comment pre { margin: 0.6rem 0; } .prose-comment .md-code-block { margin: 0.6rem 0; }
.prose-comment h1, .prose-comment h2, .prose-comment h3, .prose-comment h4 { .prose-comment h1, .prose-comment h2, .prose-comment h3, .prose-comment h4 {
font-size: 1em; font-size: 1em;
margin: 0.6rem 0 0.3rem; margin: 0.6rem 0 0.3rem;

View File

@@ -4,7 +4,6 @@ import { useRef, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { Trash2, MessagesSquare, ChevronRight, ChevronDown, BadgeCheck } from "lucide-react"; import { Trash2, MessagesSquare, ChevronRight, ChevronDown, BadgeCheck } from "lucide-react";
import ReactMarkdown from "react-markdown";
import { import {
apiCreateComment, apiCreateComment,
apiDeleteComment, apiDeleteComment,
@@ -18,6 +17,7 @@ import { canModerateBoard } from "@/lib/roles";
import { toast } from "@/lib/toast"; import { toast } from "@/lib/toast";
import Avatar from "./Avatar"; import Avatar from "./Avatar";
import Pagination from "./Pagination"; import Pagination from "./Pagination";
import MarkdownBodyClient from "@/components/MarkdownBodyClient";
interface CommentSectionProps { interface CommentSectionProps {
postId: string; postId: string;
@@ -294,8 +294,11 @@ export default function CommentSection({
</> </>
)} )}
</div> </div>
<div className="prose-content prose-comment mt-0.5"> <div className="mt-0.5">
<ReactMarkdown>{c.content}</ReactMarkdown> <MarkdownBodyClient
content={c.content}
className="prose-content prose-comment"
/>
</div> </div>
{/* 底部行:时间 · 回复(左),删除(右) */} {/* 底部行:时间 · 回复(左),删除(右) */}
<div className="flex items-center justify-between mt-1.5"> <div className="flex items-center justify-between mt-1.5">
@@ -398,9 +401,10 @@ export default function CommentSection({
</a> </a>
)} )}
</div> </div>
<div className="prose-content prose-comment"> <MarkdownBodyClient
<ReactMarkdown>{c.content}</ReactMarkdown> content={c.content}
</div> className="prose-content prose-comment"
/>
{/* 底部行:时间 · 回复(左),删除(右) */} {/* 底部行:时间 · 回复(左),删除(右) */}
<div className="flex items-center justify-between mt-2"> <div className="flex items-center justify-between mt-2">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">

View File

@@ -1,4 +1,5 @@
import ReactMarkdown, { type Components } from "react-markdown"; import type { Components } from "react-markdown";
import { renderMarkdownToReact } from "@/components/markdown/renderMarkdown";
type MarkdownBodyProps = { type MarkdownBodyProps = {
content: string; content: string;
@@ -6,15 +7,15 @@ type MarkdownBodyProps = {
components?: Components; components?: Components;
}; };
/** 站点统一 Markdown 渲染:详情页 / 发帖预览共用 prose-content */ /**
export default function MarkdownBody({ * 站点统一 Markdown 渲染(服务端):GFM + Shiki 双主题、图箱、代码块复制/折叠。
* 客户端预览请用 MarkdownBodyClient。
*/
export default async function MarkdownBody({
content, content,
className = "prose-content", className = "prose-content",
components, components,
}: MarkdownBodyProps) { }: MarkdownBodyProps) {
return ( const tree = await renderMarkdownToReact(content, components);
<div className={className}> return <div className={className}>{tree}</div>;
<ReactMarkdown components={components}>{content || ""}</ReactMarkdown>
</div>
);
} }

View File

@@ -0,0 +1,32 @@
"use client";
import ReactMarkdown, { type Components } from "react-markdown";
import remarkGfm from "remark-gfm";
import { mergeMarkdownComponents } from "@/components/markdown/mergeComponents";
type MarkdownBodyClientProps = {
content: string;
className?: string;
components?: Components;
};
/**
* 客户端 Markdown(发帖预览 / 评论):GFM + 图箱 / 代码折叠复制。
* 语法高亮在 MdCodeBlock 内按需用 Shiki 补齐。
*/
export default function MarkdownBodyClient({
content,
className = "prose-content",
components,
}: MarkdownBodyClientProps) {
return (
<div className={className}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={mergeMarkdownComponents(components)}
>
{content || ""}
</ReactMarkdown>
</div>
);
}

View File

@@ -17,7 +17,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { apiUploadPostImage } from "@/lib/api"; import { apiUploadPostImage } from "@/lib/api";
import { toast } from "@/lib/toast"; import { toast } from "@/lib/toast";
import MarkdownBody from "@/components/MarkdownBody"; import MarkdownBodyClient from "@/components/MarkdownBodyClient";
type MarkdownEditorProps = { type MarkdownEditorProps = {
value: string; value: string;
@@ -304,7 +304,7 @@ export default function MarkdownEditor({
) : ( ) : (
<div className="j13-md-preview"> <div className="j13-md-preview">
{value.trim() ? ( {value.trim() ? (
<MarkdownBody content={value} /> <MarkdownBodyClient content={value} />
) : ( ) : (
<p className="meta text-center py-16">暂无内容可预览</p> <p className="meta text-center py-16">暂无内容可预览</p>
)} )}

View File

@@ -0,0 +1,122 @@
"use client";
import { useEffect, useCallback } from "react";
import { createPortal } from "react-dom";
import { X, ChevronLeft, ChevronRight } from "lucide-react";
export type LightboxItem = {
src: string;
alt?: string;
};
type ImageLightboxProps = {
items: LightboxItem[];
index: number;
onClose: () => void;
onIndexChange: (index: number) => void;
};
/** 全屏图箱:Esc 关闭,多图时左右切换 */
export default function ImageLightbox({
items,
index,
onClose,
onIndexChange,
}: ImageLightboxProps) {
const total = items.length;
const current = items[index];
const hasNav = total > 1;
const goPrev = useCallback(() => {
if (!hasNav) return;
onIndexChange((index - 1 + total) % total);
}, [hasNav, index, total, onIndexChange]);
const goNext = useCallback(() => {
if (!hasNav) return;
onIndexChange((index + 1) % total);
}, [hasNav, index, total, onIndexChange]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
else if (e.key === "ArrowLeft") goPrev();
else if (e.key === "ArrowRight") goNext();
};
window.addEventListener("keydown", onKey);
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
window.removeEventListener("keydown", onKey);
document.body.style.overflow = prev;
};
}, [onClose, goPrev, goNext]);
if (!current || typeof document === "undefined") return null;
return createPortal(
<div
className="md-lightbox"
role="dialog"
aria-modal="true"
aria-label="图片预览"
onClick={onClose}
>
<button
type="button"
className="md-lightbox-close"
aria-label="关闭"
onClick={onClose}
>
<X size={22} />
</button>
{hasNav && (
<>
<button
type="button"
className="md-lightbox-nav md-lightbox-prev"
aria-label="上一张"
onClick={(e) => {
e.stopPropagation();
goPrev();
}}
>
<ChevronLeft size={28} />
</button>
<button
type="button"
className="md-lightbox-nav md-lightbox-next"
aria-label="下一张"
onClick={(e) => {
e.stopPropagation();
goNext();
}}
>
<ChevronRight size={28} />
</button>
<div className="md-lightbox-counter" onClick={(e) => e.stopPropagation()}>
{index + 1} / {total}
</div>
</>
)}
<figure
className="md-lightbox-figure"
onClick={(e) => e.stopPropagation()}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={current.src}
alt={current.alt || ""}
className="md-lightbox-img"
draggable={false}
/>
{current.alt ? (
<figcaption className="md-lightbox-caption">{current.alt}</figcaption>
) : null}
</figure>
</div>,
document.body
);
}

View File

@@ -0,0 +1,182 @@
"use client";
import {
useMemo,
useRef,
useState,
useEffect,
type HTMLAttributes,
type ReactNode,
} from "react";
import { Check, Copy, ChevronDown, ChevronUp } from "lucide-react";
import { toast } from "@/lib/toast";
const COLLAPSE_LINE_THRESHOLD = 5;
/** 约 5 行代码高度(含行高) */
const COLLAPSED_MAX_HEIGHT = "7.75rem";
type MdCodeBlockProps = HTMLAttributes<HTMLPreElement> & {
children?: ReactNode;
};
function extractText(node: ReactNode): string {
if (node == null || typeof node === "boolean") return "";
if (typeof node === "string" || typeof node === "number") return String(node);
if (Array.isArray(node)) return node.map(extractText).join("");
if (typeof node === "object" && "props" in node) {
const el = node as { props?: { children?: ReactNode } };
return extractText(el.props?.children);
}
return "";
}
function countLines(pre: HTMLElement | null, fallbackText: string): number {
if (pre) {
const lineEls = pre.querySelectorAll(".line, [data-line]");
if (lineEls.length > 0) return lineEls.length;
}
const text = fallbackText.replace(/\n$/, "");
if (!text) return 0;
return text.split("\n").length;
}
function readLanguage(
el: HTMLElement | null,
className?: string,
dataLanguage?: string
): string {
const fromData = dataLanguage || el?.getAttribute("data-language") || "";
if (fromData) return fromData;
const cls = [className, el?.className].filter(Boolean).join(" ");
const m = cls.match(/language-([\w+-]+)/);
return m?.[1] || "";
}
/** Markdown 代码块:语言标签、复制、>5 行默认折叠;客户端可补 Shiki 双主题 */
export default function MdCodeBlock({ children, className, ...rest }: MdCodeBlockProps) {
const hostRef = useRef<HTMLDivElement>(null);
const plainText = useMemo(() => extractText(children), [children]);
const dataLanguage =
typeof (rest as Record<string, unknown>)["data-language"] === "string"
? String((rest as Record<string, unknown>)["data-language"])
: undefined;
const approxLines = useMemo(() => countLines(null, plainText), [plainText]);
const alreadyShiki =
typeof className === "string" && className.includes("shiki");
const [copied, setCopied] = useState(false);
const [expanded, setExpanded] = useState(false);
const [lineCount, setLineCount] = useState(approxLines);
const [language, setLanguage] = useState(() =>
readLanguage(null, className, dataLanguage)
);
const [clientHtml, setClientHtml] = useState<string | null>(null);
useEffect(() => {
const host = hostRef.current;
const pre = host?.querySelector("pre") ?? null;
setLineCount(countLines(pre, plainText));
setLanguage(readLanguage(pre, className, dataLanguage));
}, [plainText, className, dataLanguage, clientHtml]);
// 服务端已着色时跳过;客户端预览动态补双主题
useEffect(() => {
if (alreadyShiki || !plainText.trim()) return;
const lang = readLanguage(null, className, dataLanguage) || "plaintext";
let cancelled = false;
(async () => {
try {
const { codeToHtml } = await import("shiki");
const html = await codeToHtml(plainText.replace(/\n$/, ""), {
lang: lang === "text" ? "plaintext" : lang,
themes: {
light: "github-light",
dark: "github-dark-dimmed",
},
defaultColor: false,
});
if (!cancelled) setClientHtml(html);
} catch {
// 未知语言:保持纯文本
}
})();
return () => {
cancelled = true;
};
}, [plainText, className, dataLanguage, alreadyShiki]);
const effectiveLines = lineCount > 0 ? lineCount : approxLines;
const canCollapse = effectiveLines > COLLAPSE_LINE_THRESHOLD;
const collapsed = canCollapse && !expanded;
const onCopy = async () => {
const pre = hostRef.current?.querySelector("pre");
const text =
pre?.innerText?.replace(/\n$/, "") || plainText.replace(/\n$/, "");
if (!text) return;
try {
await navigator.clipboard.writeText(text);
setCopied(true);
window.setTimeout(() => setCopied(false), 1600);
} catch {
toast("复制失败,请手动选择文本");
}
};
const preClass = ["md-code-pre", className].filter(Boolean).join(" ");
return (
<div className="md-code-block">
<div className="md-code-toolbar">
<span className="md-code-lang">{language || "text"}</span>
<button
type="button"
className="md-code-copy"
onClick={onCopy}
aria-label={copied ? "已复制" : "复制代码"}
>
{copied ? <Check size={14} /> : <Copy size={14} />}
<span>{copied ? "已复制" : "复制"}</span>
</button>
</div>
<div
ref={hostRef}
className={`md-code-body${collapsed ? " md-code-collapsed" : ""}`}
style={collapsed ? { maxHeight: COLLAPSED_MAX_HEIGHT } : undefined}
>
{clientHtml ? (
<div
className="md-code-shiki-host"
dangerouslySetInnerHTML={{ __html: clientHtml }}
/>
) : (
<pre className={preClass} {...rest}>
{children}
</pre>
)}
{collapsed && <div className="md-code-fade" aria-hidden="true" />}
</div>
{canCollapse && (
<button
type="button"
className="md-code-fold"
onClick={() => setExpanded((v) => !v)}
>
{expanded ? (
<>
<ChevronUp size={14} />
收起
</>
) : (
<>
<ChevronDown size={14} />
展开全部({effectiveLines} 行)
</>
)}
</button>
)}
</div>
);
}

View File

@@ -0,0 +1,76 @@
"use client";
import { useState, useCallback, type ImgHTMLAttributes } from "react";
import ImageLightbox, { type LightboxItem } from "./ImageLightbox";
type MdImageProps = ImgHTMLAttributes<HTMLImageElement>;
function collectGallery(from: HTMLImageElement): { items: LightboxItem[]; index: number } {
const root = from.closest(".prose-content");
const nodes = root
? Array.from(root.querySelectorAll<HTMLImageElement>("img.md-content-img[src]"))
: [from];
const items: LightboxItem[] = nodes
.map((el) => ({
src: el.currentSrc || el.getAttribute("src") || "",
alt: el.getAttribute("alt") || undefined,
}))
.filter((it) => !!it.src);
const selfSrc = from.currentSrc || from.getAttribute("src") || "";
let index = items.findIndex((it) => it.src === selfSrc);
if (index < 0) index = 0;
if (items.length === 0 && selfSrc) {
return { items: [{ src: selfSrc, alt: from.alt || undefined }], index: 0 };
}
return { items, index };
}
/** Markdown 正文图片:点击进入图箱,同文多图可切换 */
export default function MdImage({ src, alt, title, className, ...rest }: MdImageProps) {
const [broken, setBroken] = useState(false);
const [lightbox, setLightbox] = useState<{
items: LightboxItem[];
index: number;
} | null>(null);
const open = useCallback((el: HTMLImageElement) => {
const gallery = collectGallery(el);
if (gallery.items.length === 0) return;
setLightbox(gallery);
}, []);
if (!src || broken) {
return (
<span className="md-img-fallback" role="img" aria-label={alt || "图片加载失败"}>
{alt || "图片加载失败"}
</span>
);
}
return (
<>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
{...rest}
src={src}
alt={alt || ""}
title={title}
className={`md-content-img${className ? ` ${className}` : ""}`}
loading="lazy"
decoding="async"
onClick={(e) => open(e.currentTarget)}
onError={() => setBroken(true)}
/>
{lightbox && (
<ImageLightbox
items={lightbox.items}
index={lightbox.index}
onClose={() => setLightbox(null)}
onIndexChange={(i) =>
setLightbox((prev) => (prev ? { ...prev, index: i } : prev))
}
/>
)}
</>
);
}

View File

@@ -0,0 +1,38 @@
"use client";
import type { AnchorHTMLAttributes } from "react";
type MdLinkProps = AnchorHTMLAttributes<HTMLAnchorElement>;
function isExternalHref(href?: string): boolean {
if (!href) return false;
if (href.startsWith("/") || href.startsWith("#") || href.startsWith("?")) {
return false;
}
if (href.startsWith("mailto:") || href.startsWith("tel:")) return false;
try {
if (typeof window !== "undefined") {
const url = new URL(href, window.location.origin);
return url.origin !== window.location.origin;
}
} catch {
/* 相对或非法 URL 当站内 */
}
return /^https?:\/\//i.test(href);
}
/** Markdown 链接:外链新开页 */
export default function MdLink({ href, children, ...rest }: MdLinkProps) {
const external = isExternalHref(href);
return (
<a
{...rest}
href={href}
{...(external
? { target: "_blank", rel: "noopener noreferrer" }
: {})}
>
{children}
</a>
);
}

View File

@@ -0,0 +1,16 @@
import type { Components } from "react-markdown";
import MdImage from "./MdImage";
import MdCodeBlock from "./MdCodeBlock";
import MdLink from "./MdLink";
/** 默认自定义标签:图箱 / 代码块 / 外链;可被调用方浅合并覆盖 */
export function mergeMarkdownComponents(
overrides?: Components
): Components {
return {
img: MdImage,
pre: MdCodeBlock,
a: MdLink,
...overrides,
};
}

View File

@@ -0,0 +1,45 @@
import type { Components } from "react-markdown";
import type { ReactElement } from "react";
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
import { toJsxRuntime } from "hast-util-to-jsx-runtime";
import { unified } from "unified";
import remarkParse from "remark-parse";
import remarkGfm from "remark-gfm";
import remarkRehype from "remark-rehype";
import rehypePrettyCode from "rehype-pretty-code";
import type { Root } from "hast";
import { mergeMarkdownComponents } from "./mergeComponents";
export const prettyCodeOptions = {
theme: {
light: "github-light",
dark: "github-dark-dimmed",
},
keepBackground: false,
defaultLang: "plaintext",
} as const;
/** 服务端异步管线:GFM + Shiki 双主题高亮 → React 树 */
export async function renderMarkdownToReact(
content: string,
overrides?: Components
): Promise<ReactElement> {
const processor = unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkRehype)
.use(rehypePrettyCode, { ...prettyCodeOptions });
const mdast = processor.parse(content || "");
const hast = (await processor.run(mdast)) as Root;
return toJsxRuntime(hast, {
Fragment,
jsx,
jsxs,
components: mergeMarkdownComponents(overrides) as Record<
string,
React.ElementType
>,
}) as ReactElement;
}

View File

@@ -8,12 +8,19 @@
"name": "jiang13-bbs-frontend", "name": "jiang13-bbs-frontend",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"hast-util-to-jsx-runtime": "^2.3.6",
"lucide-react": "^0.460.0", "lucide-react": "^0.460.0",
"next": "^16.0.0", "next": "^16.0.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-easy-crop": "^6.2.3", "react-easy-crop": "^6.2.3",
"react-markdown": "^9.0.1" "react-markdown": "^9.0.1",
"rehype-pretty-code": "^0.14.5",
"remark-gfm": "^4.0.1",
"remark-parse": "^11.0.0",
"remark-rehype": "^11.1.2",
"shiki": "^4.4.3",
"unified": "^11.0.5"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4.0.0", "@tailwindcss/postcss": "^4.0.0",
@@ -792,6 +799,106 @@
"node": ">= 10" "node": ">= 10"
} }
}, },
"node_modules/@shikijs/core": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/@shikijs/core/-/core-4.4.3.tgz",
"integrity": "sha512-QCR4q2ZO/ILJEuwiBMel4wdcTDb1JGwfjKTxPDF6x8ixOaluPrVqIn06C99AcRPhmYlBR56d/Fb+GN58GzExpg==",
"license": "MIT",
"dependencies": {
"@shikijs/primitive": "4.4.3",
"@shikijs/types": "4.4.3",
"@shikijs/vscode-textmate": "^10.0.2",
"@types/hast": "^3.0.5",
"hast-util-to-html": "^9.0.5"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@shikijs/engine-javascript": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-4.4.3.tgz",
"integrity": "sha512-FbOjFJp9VLdo1Wevs10BBtVxiTWwNLqZh5Gkhjgda/ioL15YOgeSl9n+6XMa3qRlPQzfhFNe641SrynFHYG0nQ==",
"license": "MIT",
"dependencies": {
"@shikijs/types": "4.4.3",
"@shikijs/vscode-textmate": "^10.0.2",
"oniguruma-to-es": "^4.3.6"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@shikijs/engine-oniguruma": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-4.4.3.tgz",
"integrity": "sha512-EcOQkxdxGQrc1Row/cC2c96/v1dbZqGnEVu1qTuT/MJmp6+cXCvQussowVmCv5Tqr3KuY3c7IbM6HTW3LJ1k9w==",
"license": "MIT",
"dependencies": {
"@shikijs/types": "4.4.3",
"@shikijs/vscode-textmate": "^10.0.2"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@shikijs/langs": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-4.4.3.tgz",
"integrity": "sha512-ePic0yfAJGOF83D5wBHK/00EjK65oahBYxFk5epgq33WRv7X9UuxLEV8PtR0szC0z8dl7INIpIodB99JRFlR+A==",
"license": "MIT",
"dependencies": {
"@shikijs/types": "4.4.3"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@shikijs/primitive": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/@shikijs/primitive/-/primitive-4.4.3.tgz",
"integrity": "sha512-m0wBeLDQDeIxRdUmrCPdQqfuUamDwRL5isCfYbguKD6NiaKpVbsv+3J81DyIKgNW5h4WAIIr8T4EkgQrBBxvaQ==",
"license": "MIT",
"dependencies": {
"@shikijs/types": "4.4.3",
"@shikijs/vscode-textmate": "^10.0.2",
"@types/hast": "^3.0.5"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@shikijs/themes": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-4.4.3.tgz",
"integrity": "sha512-w8UHjeUnIR965KMWJHUPXOc2mNJUnK3vpVLYLvw5IYU2mnTTJ89E24OrJDBNiJDQ0qzb0tc4l7mrIXx5cFeIyw==",
"license": "MIT",
"dependencies": {
"@shikijs/types": "4.4.3"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@shikijs/types": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/@shikijs/types/-/types-4.4.3.tgz",
"integrity": "sha512-UEJxmRR++MAGR6hugn0vgVS2W/6lWAts84FFSrnlH9sP0LNol7E5+NQ792pH8liWUhyMyjhTgSUH3k7iD7tc5g==",
"license": "MIT",
"dependencies": {
"@shikijs/vscode-textmate": "^10.0.2",
"@types/hast": "^3.0.5"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@shikijs/vscode-textmate": {
"version": "10.0.2",
"resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz",
"integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==",
"license": "MIT"
},
"node_modules/@swc/helpers": { "node_modules/@swc/helpers": {
"version": "0.5.23", "version": "0.5.23",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz",
@@ -1363,6 +1470,30 @@
"node": ">=10.13.0" "node": ">=10.13.0"
} }
}, },
"node_modules/entities": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/escape-string-regexp": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz",
"integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/estree-util-is-identifier-name": { "node_modules/estree-util-is-identifier-name": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz",
@@ -1386,6 +1517,80 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/hast-util-from-html": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/hast-util-from-html/-/hast-util-from-html-2.0.3.tgz",
"integrity": "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"devlop": "^1.1.0",
"hast-util-from-parse5": "^8.0.0",
"parse5": "^7.0.0",
"vfile": "^6.0.0",
"vfile-message": "^4.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-from-parse5": {
"version": "8.0.3",
"resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz",
"integrity": "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"@types/unist": "^3.0.0",
"devlop": "^1.0.0",
"hastscript": "^9.0.0",
"property-information": "^7.0.0",
"vfile": "^6.0.0",
"vfile-location": "^5.0.0",
"web-namespaces": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-parse-selector": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz",
"integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-to-html": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz",
"integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"@types/unist": "^3.0.0",
"ccount": "^2.0.0",
"comma-separated-tokens": "^2.0.0",
"hast-util-whitespace": "^3.0.0",
"html-void-elements": "^3.0.0",
"mdast-util-to-hast": "^13.0.0",
"property-information": "^7.0.0",
"space-separated-tokens": "^2.0.0",
"stringify-entities": "^4.0.0",
"zwitch": "^2.0.4"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-to-jsx-runtime": { "node_modules/hast-util-to-jsx-runtime": {
"version": "2.3.6", "version": "2.3.6",
"resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz",
@@ -1413,6 +1618,19 @@
"url": "https://opencollective.com/unified" "url": "https://opencollective.com/unified"
} }
}, },
"node_modules/hast-util-to-string": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/hast-util-to-string/-/hast-util-to-string-3.0.1.tgz",
"integrity": "sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-whitespace": { "node_modules/hast-util-whitespace": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz",
@@ -1426,6 +1644,23 @@
"url": "https://opencollective.com/unified" "url": "https://opencollective.com/unified"
} }
}, },
"node_modules/hastscript": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz",
"integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"comma-separated-tokens": "^2.0.0",
"hast-util-parse-selector": "^4.0.0",
"property-information": "^7.0.0",
"space-separated-tokens": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/html-url-attributes": { "node_modules/html-url-attributes": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
@@ -1436,6 +1671,16 @@
"url": "https://opencollective.com/unified" "url": "https://opencollective.com/unified"
} }
}, },
"node_modules/html-void-elements": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz",
"integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/inline-style-parser": { "node_modules/inline-style-parser": {
"version": "0.2.7", "version": "0.2.7",
"resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz",
@@ -1810,6 +2055,32 @@
"@jridgewell/sourcemap-codec": "^1.5.5" "@jridgewell/sourcemap-codec": "^1.5.5"
} }
}, },
"node_modules/markdown-table": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz",
"integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/mdast-util-find-and-replace": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz",
"integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==",
"license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"escape-string-regexp": "^5.0.0",
"unist-util-is": "^6.0.0",
"unist-util-visit-parents": "^6.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-from-markdown": { "node_modules/mdast-util-from-markdown": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz",
@@ -1834,6 +2105,107 @@
"url": "https://opencollective.com/unified" "url": "https://opencollective.com/unified"
} }
}, },
"node_modules/mdast-util-gfm": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz",
"integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==",
"license": "MIT",
"dependencies": {
"mdast-util-from-markdown": "^2.0.0",
"mdast-util-gfm-autolink-literal": "^2.0.0",
"mdast-util-gfm-footnote": "^2.0.0",
"mdast-util-gfm-strikethrough": "^2.0.0",
"mdast-util-gfm-table": "^2.0.0",
"mdast-util-gfm-task-list-item": "^2.0.0",
"mdast-util-to-markdown": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-gfm-autolink-literal": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz",
"integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==",
"license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"ccount": "^2.0.0",
"devlop": "^1.0.0",
"mdast-util-find-and-replace": "^3.0.0",
"micromark-util-character": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-gfm-footnote": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz",
"integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==",
"license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"devlop": "^1.1.0",
"mdast-util-from-markdown": "^2.0.0",
"mdast-util-to-markdown": "^2.0.0",
"micromark-util-normalize-identifier": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-gfm-strikethrough": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz",
"integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==",
"license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"mdast-util-from-markdown": "^2.0.0",
"mdast-util-to-markdown": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-gfm-table": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz",
"integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==",
"license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"devlop": "^1.0.0",
"markdown-table": "^3.0.0",
"mdast-util-from-markdown": "^2.0.0",
"mdast-util-to-markdown": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-gfm-task-list-item": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz",
"integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==",
"license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"devlop": "^1.0.0",
"mdast-util-from-markdown": "^2.0.0",
"mdast-util-to-markdown": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-mdx-expression": { "node_modules/mdast-util-mdx-expression": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz",
@@ -2032,6 +2404,127 @@
"micromark-util-types": "^2.0.0" "micromark-util-types": "^2.0.0"
} }
}, },
"node_modules/micromark-extension-gfm": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz",
"integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==",
"license": "MIT",
"dependencies": {
"micromark-extension-gfm-autolink-literal": "^2.0.0",
"micromark-extension-gfm-footnote": "^2.0.0",
"micromark-extension-gfm-strikethrough": "^2.0.0",
"micromark-extension-gfm-table": "^2.0.0",
"micromark-extension-gfm-tagfilter": "^2.0.0",
"micromark-extension-gfm-task-list-item": "^2.0.0",
"micromark-util-combine-extensions": "^2.0.0",
"micromark-util-types": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/micromark-extension-gfm-autolink-literal": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz",
"integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==",
"license": "MIT",
"dependencies": {
"micromark-util-character": "^2.0.0",
"micromark-util-sanitize-uri": "^2.0.0",
"micromark-util-symbol": "^2.0.0",
"micromark-util-types": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/micromark-extension-gfm-footnote": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz",
"integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==",
"license": "MIT",
"dependencies": {
"devlop": "^1.0.0",
"micromark-core-commonmark": "^2.0.0",
"micromark-factory-space": "^2.0.0",
"micromark-util-character": "^2.0.0",
"micromark-util-normalize-identifier": "^2.0.0",
"micromark-util-sanitize-uri": "^2.0.0",
"micromark-util-symbol": "^2.0.0",
"micromark-util-types": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/micromark-extension-gfm-strikethrough": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz",
"integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==",
"license": "MIT",
"dependencies": {
"devlop": "^1.0.0",
"micromark-util-chunked": "^2.0.0",
"micromark-util-classify-character": "^2.0.0",
"micromark-util-resolve-all": "^2.0.0",
"micromark-util-symbol": "^2.0.0",
"micromark-util-types": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/micromark-extension-gfm-table": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.2.tgz",
"integrity": "sha512-pRzm4kDTu0MjlmBkxmS9yYhw60nncfcEwu9NNdPFSQEFXS95ZKyIIyTSHu/o3ReBUrLKYEq+7YaXCRn/bPB4MA==",
"license": "MIT",
"dependencies": {
"devlop": "^1.0.0",
"micromark-factory-space": "^2.0.0",
"micromark-util-character": "^2.0.0",
"micromark-util-symbol": "^2.0.0",
"micromark-util-types": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/micromark-extension-gfm-tagfilter": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz",
"integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==",
"license": "MIT",
"dependencies": {
"micromark-util-types": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/micromark-extension-gfm-task-list-item": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz",
"integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==",
"license": "MIT",
"dependencies": {
"devlop": "^1.0.0",
"micromark-factory-space": "^2.0.0",
"micromark-util-character": "^2.0.0",
"micromark-util-symbol": "^2.0.0",
"micromark-util-types": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/micromark-factory-destination": { "node_modules/micromark-factory-destination": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz",
@@ -2516,6 +3009,23 @@
"integrity": "sha512-1OnlAPZ3zgrk8B91HyRj+eVv+kS5u+Z0SCsak6Xil/kmgEia50ga7zfkumayonZrImffAxPU/5WcyGhzetHNPA==", "integrity": "sha512-1OnlAPZ3zgrk8B91HyRj+eVv+kS5u+Z0SCsak6Xil/kmgEia50ga7zfkumayonZrImffAxPU/5WcyGhzetHNPA==",
"license": "BSD-3-Clause" "license": "BSD-3-Clause"
}, },
"node_modules/oniguruma-parser": {
"version": "0.12.2",
"resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.2.tgz",
"integrity": "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==",
"license": "MIT"
},
"node_modules/oniguruma-to-es": {
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.6.tgz",
"integrity": "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==",
"license": "MIT",
"dependencies": {
"oniguruma-parser": "^0.12.2",
"regex": "^6.1.0",
"regex-recursion": "^6.0.2"
}
},
"node_modules/parse-entities": { "node_modules/parse-entities": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz",
@@ -2541,6 +3051,24 @@
"integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/parse-numeric-range": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/parse-numeric-range/-/parse-numeric-range-1.3.0.tgz",
"integrity": "sha512-twN+njEipszzlMJd4ONUYgSfZPDxgHhT9Ahed5uTigpQn90FggW4SA/AIPq/6a149fTbE9qBEcSwE3FAEp6wQQ==",
"license": "ISC"
},
"node_modules/parse5": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
"license": "MIT",
"dependencies": {
"entities": "^6.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -2647,6 +3175,83 @@
"react": ">=18" "react": ">=18"
} }
}, },
"node_modules/regex": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz",
"integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==",
"license": "MIT",
"dependencies": {
"regex-utilities": "^2.3.0"
}
},
"node_modules/regex-recursion": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz",
"integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==",
"license": "MIT",
"dependencies": {
"regex-utilities": "^2.3.0"
}
},
"node_modules/regex-utilities": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz",
"integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==",
"license": "MIT"
},
"node_modules/rehype-parse": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/rehype-parse/-/rehype-parse-9.0.1.tgz",
"integrity": "sha512-ksCzCD0Fgfh7trPDxr2rSylbwq9iYDkSn8TCDmEJ49ljEUBxDVCzCHv7QNzZOfODanX4+bWQ4WZqLCRWYLfhag==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"hast-util-from-html": "^2.0.0",
"unified": "^11.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/rehype-pretty-code": {
"version": "0.14.5",
"resolved": "https://registry.npmjs.org/rehype-pretty-code/-/rehype-pretty-code-0.14.5.tgz",
"integrity": "sha512-+DhMMNMgOwmE0xu2RmagN63nR0GRObPIQ9TuoNdUYYIDcCr8nWXU1CJUG6yca7vg99Gp5d+62A/+ZvksmsQXjA==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.4",
"hast-util-to-string": "^3.0.0",
"parse-numeric-range": "^1.3.0",
"rehype-parse": "^9.0.0",
"unified": "^11.0.5",
"unist-util-visit": "^5.0.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"shiki": "^1.0.0 || ^2.0.0 || ^3.0.0 || ^4.0.0"
}
},
"node_modules/remark-gfm": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz",
"integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==",
"license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"mdast-util-gfm": "^3.0.0",
"micromark-extension-gfm": "^3.0.0",
"remark-parse": "^11.0.0",
"remark-stringify": "^11.0.0",
"unified": "^11.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/remark-parse": { "node_modules/remark-parse": {
"version": "11.0.0", "version": "11.0.0",
"resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz",
@@ -2680,6 +3285,21 @@
"url": "https://opencollective.com/unified" "url": "https://opencollective.com/unified"
} }
}, },
"node_modules/remark-stringify": {
"version": "11.0.0",
"resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz",
"integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==",
"license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"mdast-util-to-markdown": "^2.0.0",
"unified": "^11.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/scheduler": { "node_modules/scheduler": {
"version": "0.28.0", "version": "0.28.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz",
@@ -2749,6 +3369,25 @@
} }
} }
}, },
"node_modules/shiki": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/shiki/-/shiki-4.4.3.tgz",
"integrity": "sha512-Mb/GvXPHBAXdgGIcnfU5L3ldpn1XcxrGkPHwqgRx17/I2XRfqlFKk2vGkHWINn1kdXvzJZeuO3is6I9KLPFm0g==",
"license": "MIT",
"dependencies": {
"@shikijs/core": "4.4.3",
"@shikijs/engine-javascript": "4.4.3",
"@shikijs/engine-oniguruma": "4.4.3",
"@shikijs/langs": "4.4.3",
"@shikijs/themes": "4.4.3",
"@shikijs/types": "4.4.3",
"@shikijs/vscode-textmate": "^10.0.2",
"@types/hast": "^3.0.5"
},
"engines": {
"node": ">=20"
}
},
"node_modules/source-map-js": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -2992,6 +3631,20 @@
"url": "https://opencollective.com/unified" "url": "https://opencollective.com/unified"
} }
}, },
"node_modules/vfile-location": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz",
"integrity": "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==",
"license": "MIT",
"dependencies": {
"@types/unist": "^3.0.0",
"vfile": "^6.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/vfile-message": { "node_modules/vfile-message": {
"version": "4.0.3", "version": "4.0.3",
"resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz",
@@ -3006,6 +3659,16 @@
"url": "https://opencollective.com/unified" "url": "https://opencollective.com/unified"
} }
}, },
"node_modules/web-namespaces": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz",
"integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/zwitch": { "node_modules/zwitch": {
"version": "2.0.4", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",

View File

@@ -9,12 +9,19 @@
"lint": "next lint" "lint": "next lint"
}, },
"dependencies": { "dependencies": {
"hast-util-to-jsx-runtime": "^2.3.6",
"lucide-react": "^0.460.0", "lucide-react": "^0.460.0",
"next": "^16.0.0", "next": "^16.0.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-easy-crop": "^6.2.3", "react-easy-crop": "^6.2.3",
"react-markdown": "^9.0.1" "react-markdown": "^9.0.1",
"rehype-pretty-code": "^0.14.5",
"remark-gfm": "^4.0.1",
"remark-parse": "^11.0.0",
"remark-rehype": "^11.1.2",
"shiki": "^4.4.3",
"unified": "^11.0.5"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4.0.0", "@tailwindcss/postcss": "^4.0.0",