From 2056d6a3f9f9dfcbc57954a70d991e369ab97dcb Mon Sep 17 00:00:00 2001 From: freefire Date: Wed, 16 Sep 2026 05:41:25 +0800 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20Markdown=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=20GFM=E3=80=81=E4=BB=A3=E7=A0=81=E9=AB=98=E4=BA=AE=E4=B8=8E?= =?UTF-8?q?=E5=9B=BE=E7=89=87=E7=81=AF=E7=AE=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 统一 SSR/客户端渲染路径,评论与预览共用增强组件。 Co-authored-by: Cursor --- frontend/app/announcement/[id]/page.tsx | 6 +- frontend/app/globals.css | 261 ++++++- frontend/components/CommentSection.tsx | 16 +- frontend/components/MarkdownBody.tsx | 17 +- frontend/components/MarkdownBodyClient.tsx | 32 + frontend/components/MarkdownEditor.tsx | 4 +- .../components/markdown/ImageLightbox.tsx | 122 ++++ frontend/components/markdown/MdCodeBlock.tsx | 182 +++++ frontend/components/markdown/MdImage.tsx | 76 ++ frontend/components/markdown/MdLink.tsx | 38 + .../components/markdown/mergeComponents.tsx | 16 + .../components/markdown/renderMarkdown.tsx | 45 ++ frontend/package-lock.json | 665 +++++++++++++++++- frontend/package.json | 9 +- 14 files changed, 1458 insertions(+), 31 deletions(-) create mode 100644 frontend/components/MarkdownBodyClient.tsx create mode 100644 frontend/components/markdown/ImageLightbox.tsx create mode 100644 frontend/components/markdown/MdCodeBlock.tsx create mode 100644 frontend/components/markdown/MdImage.tsx create mode 100644 frontend/components/markdown/MdLink.tsx create mode 100644 frontend/components/markdown/mergeComponents.tsx create mode 100644 frontend/components/markdown/renderMarkdown.tsx diff --git a/frontend/app/announcement/[id]/page.tsx b/frontend/app/announcement/[id]/page.tsx index 160f8c2..34f5e89 100644 --- a/frontend/app/announcement/[id]/page.tsx +++ b/frontend/app/announcement/[id]/page.tsx @@ -1,10 +1,10 @@ import type { Metadata } from "next"; import Link from "next/link"; import { notFound } from "next/navigation"; -import ReactMarkdown from "react-markdown"; import { ChevronRight, Megaphone } from "lucide-react"; import { fetchAnnouncementDetail } from "@/lib/api"; import { formatDate } from "@/lib/format"; +import MarkdownBody from "@/components/MarkdownBody"; interface PageProps { params: Promise<{ id: string }>; @@ -77,9 +77,7 @@ export default async function AnnouncementPage({ params }: PageProps) { className="mt-5 pt-5" style={{ borderTop: "1px solid var(--line)" }} > -
- {a.content} -
+ diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 192b3e1..58382fe 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -1702,15 +1702,28 @@ a.j13-mention-token:hover { border-radius: 6px; font-size: 0.86em; } -.prose-content pre { - background: var(--panel-2) !important; - border: 1px solid var(--line); - padding: 1.05rem 1.15rem; - border-radius: var(--r-sm); +.prose-content pre, +.prose-content .md-code-pre, +.prose-content .md-code-shiki-host pre { + background: transparent !important; + border: none; + padding: 0.85rem 1.05rem; + border-radius: 0; 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 { border-left: 3px solid var(--accent); background: var(--accent-soft); @@ -1725,19 +1738,249 @@ a.j13-mention-token:hover { text-underline-offset: 3px; font-weight: 500; } -.prose-content img { +.prose-content img, +.prose-content .md-content-img { max-width: 100%; border-radius: var(--r-sm); 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 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 全部规则,仅收紧行距与间距 */ .prose-comment { font-size: 14.5px; line-height: 1.7; } .prose-comment p { margin: 0.35rem 0; } .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 { font-size: 1em; margin: 0.6rem 0 0.3rem; diff --git a/frontend/components/CommentSection.tsx b/frontend/components/CommentSection.tsx index d81bfcc..e9d8836 100644 --- a/frontend/components/CommentSection.tsx +++ b/frontend/components/CommentSection.tsx @@ -4,7 +4,6 @@ import { useRef, useState } from "react"; import { useRouter } from "next/navigation"; import Link from "next/link"; import { Trash2, MessagesSquare, ChevronRight, ChevronDown, BadgeCheck } from "lucide-react"; -import ReactMarkdown from "react-markdown"; import { apiCreateComment, apiDeleteComment, @@ -18,6 +17,7 @@ import { canModerateBoard } from "@/lib/roles"; import { toast } from "@/lib/toast"; import Avatar from "./Avatar"; import Pagination from "./Pagination"; +import MarkdownBodyClient from "@/components/MarkdownBodyClient"; interface CommentSectionProps { postId: string; @@ -294,8 +294,11 @@ export default function CommentSection({ )} -
- {c.content} +
+
{/* 底部行:时间 · 回复(左),删除(右) */}
@@ -398,9 +401,10 @@ export default function CommentSection({ )}
-
- {c.content} -
+ {/* 底部行:时间 · 回复(左),删除(右) */}
diff --git a/frontend/components/MarkdownBody.tsx b/frontend/components/MarkdownBody.tsx index d00d752..5d18fb0 100644 --- a/frontend/components/MarkdownBody.tsx +++ b/frontend/components/MarkdownBody.tsx @@ -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 = { content: string; @@ -6,15 +7,15 @@ type MarkdownBodyProps = { components?: Components; }; -/** 站点统一 Markdown 渲染:详情页 / 发帖预览共用 prose-content */ -export default function MarkdownBody({ +/** + * 站点统一 Markdown 渲染(服务端):GFM + Shiki 双主题、图箱、代码块复制/折叠。 + * 客户端预览请用 MarkdownBodyClient。 + */ +export default async function MarkdownBody({ content, className = "prose-content", components, }: MarkdownBodyProps) { - return ( -
- {content || ""} -
- ); + const tree = await renderMarkdownToReact(content, components); + return
{tree}
; } diff --git a/frontend/components/MarkdownBodyClient.tsx b/frontend/components/MarkdownBodyClient.tsx new file mode 100644 index 0000000..b75d47b --- /dev/null +++ b/frontend/components/MarkdownBodyClient.tsx @@ -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 ( +
+ + {content || ""} + +
+ ); +} diff --git a/frontend/components/MarkdownEditor.tsx b/frontend/components/MarkdownEditor.tsx index 2c6ef27..39ecc70 100644 --- a/frontend/components/MarkdownEditor.tsx +++ b/frontend/components/MarkdownEditor.tsx @@ -17,7 +17,7 @@ import { } from "lucide-react"; import { apiUploadPostImage } from "@/lib/api"; import { toast } from "@/lib/toast"; -import MarkdownBody from "@/components/MarkdownBody"; +import MarkdownBodyClient from "@/components/MarkdownBodyClient"; type MarkdownEditorProps = { value: string; @@ -304,7 +304,7 @@ export default function MarkdownEditor({ ) : (
{value.trim() ? ( - + ) : (

暂无内容可预览

)} diff --git a/frontend/components/markdown/ImageLightbox.tsx b/frontend/components/markdown/ImageLightbox.tsx new file mode 100644 index 0000000..340b344 --- /dev/null +++ b/frontend/components/markdown/ImageLightbox.tsx @@ -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( +
+ + + {hasNav && ( + <> + + +
e.stopPropagation()}> + {index + 1} / {total} +
+ + )} + +
e.stopPropagation()} + > + {/* eslint-disable-next-line @next/next/no-img-element */} + {current.alt + {current.alt ? ( +
{current.alt}
+ ) : null} +
+
, + document.body + ); +} diff --git a/frontend/components/markdown/MdCodeBlock.tsx b/frontend/components/markdown/MdCodeBlock.tsx new file mode 100644 index 0000000..2f3d9f7 --- /dev/null +++ b/frontend/components/markdown/MdCodeBlock.tsx @@ -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 & { + 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(null); + + const plainText = useMemo(() => extractText(children), [children]); + const dataLanguage = + typeof (rest as Record)["data-language"] === "string" + ? String((rest as Record)["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(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 ( +
+
+ {language || "text"} + +
+
+ {clientHtml ? ( +
+ ) : ( +
+            {children}
+          
+ )} + {collapsed && + {canCollapse && ( + + )} +
+ ); +} diff --git a/frontend/components/markdown/MdImage.tsx b/frontend/components/markdown/MdImage.tsx new file mode 100644 index 0000000..daef85d --- /dev/null +++ b/frontend/components/markdown/MdImage.tsx @@ -0,0 +1,76 @@ +"use client"; + +import { useState, useCallback, type ImgHTMLAttributes } from "react"; +import ImageLightbox, { type LightboxItem } from "./ImageLightbox"; + +type MdImageProps = ImgHTMLAttributes; + +function collectGallery(from: HTMLImageElement): { items: LightboxItem[]; index: number } { + const root = from.closest(".prose-content"); + const nodes = root + ? Array.from(root.querySelectorAll("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 ( + + {alt || "图片加载失败"} + + ); + } + + return ( + <> + {/* eslint-disable-next-line @next/next/no-img-element */} + {alt open(e.currentTarget)} + onError={() => setBroken(true)} + /> + {lightbox && ( + setLightbox(null)} + onIndexChange={(i) => + setLightbox((prev) => (prev ? { ...prev, index: i } : prev)) + } + /> + )} + + ); +} diff --git a/frontend/components/markdown/MdLink.tsx b/frontend/components/markdown/MdLink.tsx new file mode 100644 index 0000000..dc12c3f --- /dev/null +++ b/frontend/components/markdown/MdLink.tsx @@ -0,0 +1,38 @@ +"use client"; + +import type { AnchorHTMLAttributes } from "react"; + +type MdLinkProps = AnchorHTMLAttributes; + +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 ( + + {children} + + ); +} diff --git a/frontend/components/markdown/mergeComponents.tsx b/frontend/components/markdown/mergeComponents.tsx new file mode 100644 index 0000000..91e0738 --- /dev/null +++ b/frontend/components/markdown/mergeComponents.tsx @@ -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, + }; +} diff --git a/frontend/components/markdown/renderMarkdown.tsx b/frontend/components/markdown/renderMarkdown.tsx new file mode 100644 index 0000000..40c3a97 --- /dev/null +++ b/frontend/components/markdown/renderMarkdown.tsx @@ -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 { + 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; +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 262ea83..f9149d6 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,12 +8,19 @@ "name": "jiang13-bbs-frontend", "version": "0.1.0", "dependencies": { + "hast-util-to-jsx-runtime": "^2.3.6", "lucide-react": "^0.460.0", "next": "^16.0.0", "react": "^19.0.0", "react-dom": "^19.0.0", "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": { "@tailwindcss/postcss": "^4.0.0", @@ -792,6 +799,106 @@ "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": { "version": "0.5.23", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", @@ -1363,6 +1470,30 @@ "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": { "version": "3.0.0", "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, "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": { "version": "2.3.6", "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" } }, + "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": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", @@ -1426,6 +1644,23 @@ "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": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", @@ -1436,6 +1671,16 @@ "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": { "version": "0.2.7", "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" } }, + "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": { "version": "2.0.3", "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" } }, + "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": { "version": "2.0.1", "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" } }, + "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": { "version": "2.0.1", "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==", "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": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", @@ -2541,6 +3051,24 @@ "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", "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": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -2647,6 +3175,83 @@ "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": { "version": "11.0.0", "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", @@ -2680,6 +3285,21 @@ "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": { "version": "0.28.0", "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": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -2992,6 +3631,20 @@ "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": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", @@ -3006,6 +3659,16 @@ "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": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", diff --git a/frontend/package.json b/frontend/package.json index e3da9ce..ba0e035 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,12 +9,19 @@ "lint": "next lint" }, "dependencies": { + "hast-util-to-jsx-runtime": "^2.3.6", "lucide-react": "^0.460.0", "next": "^16.0.0", "react": "^19.0.0", "react-dom": "^19.0.0", "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": { "@tailwindcss/postcss": "^4.0.0",