diff --git a/packages/next/app/[locale]/about/AboutContent.tsx b/packages/next/app/[locale]/about/AboutContent.tsx
deleted file mode 100644
index 3078076..0000000
--- a/packages/next/app/[locale]/about/AboutContent.tsx
+++ /dev/null
@@ -1,8 +0,0 @@
-"use client";
-
-import About from "@/content/about.mdx";
-import "./content.css";
-
-export default function AboutContent() {
- return ;
-}
diff --git a/packages/next/app/[locale]/about/page.tsx b/packages/next/app/[locale]/about/page.tsx
index c352a67..b0e967f 100644
--- a/packages/next/app/[locale]/about/page.tsx
+++ b/packages/next/app/[locale]/about/page.tsx
@@ -1,15 +1,16 @@
import { Header } from "@/components/shell/Header";
import { getCurrentUser } from "@/lib/userAuth";
-import AboutContent from "./AboutContent";
+import { Content } from "@/components/shell/Content";
export default async function AboutPage() {
const user = await getCurrentUser();
+
return (
<>
-
-
+
+
>
diff --git a/packages/next/app/[locale]/global.css b/packages/next/app/[locale]/global.css
index 57d07a6..6657449 100644
--- a/packages/next/app/[locale]/global.css
+++ b/packages/next/app/[locale]/global.css
@@ -101,6 +101,10 @@ a {
:root {
font-family: "Inter Variable", "MiSans VF", sans-serif;
font-optical-sizing: auto;
- font-weight: 330;
+ font-weight: 280;
}
}
+
+.content-box {
+ @apply bg-background/80 dark:bg-dark-background/80 rounded-lg;
+}
diff --git a/packages/next/app/[locale]/layout.tsx b/packages/next/app/[locale]/layout.tsx
index 5e6d24b..005d1e4 100644
--- a/packages/next/app/[locale]/layout.tsx
+++ b/packages/next/app/[locale]/layout.tsx
@@ -4,6 +4,7 @@ import React from "react";
import { routing } from "@/i18n/routing";
import { hasLocale } from "next-intl";
import { notFound } from "next/navigation";
+import { Background } from "@/components/shell/Background";
export const metadata: Metadata = {
title: "中 V 档案馆"
@@ -20,5 +21,10 @@ export default async function RootLayout({
if (!hasLocale(routing.locales, locale)) {
notFound();
}
- return <>{children}>;
+ return (
+ <>
+
+ {children}
+ >
+ );
}
diff --git a/packages/next/app/[locale]/user/[uid]/profile/LogoutButton.tsx b/packages/next/app/[locale]/user/[uid]/profile/LogoutButton.tsx
index cab7781..1c33bb2 100644
--- a/packages/next/app/[locale]/user/[uid]/profile/LogoutButton.tsx
+++ b/packages/next/app/[locale]/user/[uid]/profile/LogoutButton.tsx
@@ -1,6 +1,6 @@
"use client";
-import { FilledButton } from "@/components/ui/Buttons/FilledButton";
+import { TextButton } from "@/components/ui/Buttons/TextButton";
import { Dialog, DialogButton, DialogButtonGroup, DialogHeadline, DialogSupportingText } from "@/components/ui/Dialog";
import { Portal } from "@/components/utils/Portal";
import { useRouter } from "@/i18n/navigation";
@@ -11,13 +11,9 @@ export const LogoutButton: React.FC = () => {
const router = useRouter();
return (
<>
-
setShowDialog(true)}
- >
+ setShowDialog(true)}>
登出
-
+
;
+ }
+};
+
+interface ContentProps {
+ pageID: string;
+}
+
+export const Content: React.FC = async ({ pageID }) => {
+ const result = await sql<{ page_content: string }[]>`
+ SELECT page_content
+ FROM content
+ WHERE page_id = ${pageID}
+ `;
+ if (result.length === 0) {
+ return <>>;
+ }
+ const content = result[0].page_content.replace(/<\!--.*?-->/g, "");
+
+ try {
+ const parser = remark()
+ .use(remarkGfm)
+ .use(remarkMdx)
+ .use(remarkCollectFootnoteDefinitions)
+ .use(() => {
+ return (tree, file) => {
+ file.data.ast = tree;
+ };
+ });
+
+ const file = parser.processSync(content);
+ const mdast = file.data.ast as Root;
+ return (
+
+
+
+ );
+ } catch (e) {
+ return (
+
+
+ 渲染出现问题。
+
+ 错误信息: {e.message}
+
+ 以下是该内容的原文:
+
+
{content}
+
+ );
+ }
+};
diff --git a/packages/next/components/shell/ContentClient.tsx b/packages/next/components/shell/ContentClient.tsx
new file mode 100644
index 0000000..b0bbbdf
--- /dev/null
+++ b/packages/next/components/shell/ContentClient.tsx
@@ -0,0 +1,73 @@
+"use client";
+import { SafeMdxRenderer } from "@/lib/mdx/SafeMDX";
+import "./content.css";
+import remarkMdx from "remark-mdx";
+import remarkGfm from "remark-gfm";
+import { OptionalChidrenProps } from "@/components/ui/Dialog";
+import { remark } from "remark";
+import { Root } from "mdast";
+import remarkCollectFootnoteDefinitions from "@/lib/mdx/footnoteHelper";
+import { BackgroundDelegate } from "./Background";
+
+const 黑幕: React.FC> = ({ children }) => {
+ return (
+
+ {children}
+
+ );
+};
+
+const components = {
+ 黑幕: 黑幕,
+ center: ({ children }: { children: React.ReactNode }) => {
+ return {children};
+ },
+ 背景图片: ({ url }: { url: string }) => {
+ return ;
+ },
+ poem: ({ children }: { children: React.ReactNode }) => {
+ if (typeof children !== "string") {
+ return <>{children}>;
+ }
+ return ") }}>;
+ }
+};
+
+interface ContentProps {
+ content: string;
+}
+
+export const ContentClient: React.FC = ({ content }) => {
+ try {
+ const parser = remark()
+ .use(remarkGfm)
+ .use(remarkMdx)
+ .use(remarkCollectFootnoteDefinitions)
+ .use(() => {
+ return (tree, file) => {
+ file.data.ast = tree;
+ };
+ });
+
+ const file = parser.processSync(content);
+ const mdast = file.data.ast as Root;
+ return (
+
+
+
+ );
+ } catch (e) {
+ return (
+
+
+ 渲染出现问题。
+
+ 错误信息: {e.message}
+
+ 以下是该内容的原文:
+
+
{content}
+
+ );
+ }
+};
diff --git a/packages/next/app/[locale]/about/content.css b/packages/next/components/shell/content.css
similarity index 89%
rename from packages/next/app/[locale]/about/content.css
rename to packages/next/components/shell/content.css
index e41a1d6..97c5cc5 100644
--- a/packages/next/app/[locale]/about/content.css
+++ b/packages/next/components/shell/content.css
@@ -41,3 +41,7 @@
@apply inline;
}
}
+
+.footnote-li {
+ @apply list-outside ml-6;
+}
diff --git a/packages/next/components/ui/Dialog.tsx b/packages/next/components/ui/Dialog.tsx
index 24d75d4..4eac26b 100644
--- a/packages/next/components/ui/Dialog.tsx
+++ b/packages/next/components/ui/Dialog.tsx
@@ -18,7 +18,7 @@ export const useDisableBodyScroll = (open: boolean) => {
}, [open]);
};
-type OptionalChidrenProps> = T & {
+export type OptionalChidrenProps> = T & {
children?: React.ReactNode;
};
diff --git a/packages/next/content/about.mdx b/packages/next/content/about.mdx
deleted file mode 100644
index de549dd..0000000
--- a/packages/next/content/about.mdx
+++ /dev/null
@@ -1,60 +0,0 @@
-# 关于「中 V 档案馆」
-
-「中 V 档案馆」是一个旨在收录与展示「中文歌声合成作品」及有关信息的网站。
-
-## 创建背景与关联工作
-
-纵观整个互联网,对于「中文歌声合成」或「中文虚拟歌手」(常简称为中 V 或 VC)相关信息进行较为系统、全面地整理收集的主要有以下几个网站:
-
-- [萌娘百科](https://zh.moegirl.org.cn/):
- 收录了大量中 V 歌曲及歌姬的信息,呈现形式为传统维基(基于 [MediaWiki](https://www.mediawiki.org/))。
-- [VCPedia](https://vcpedia.cn/):
- 由原萌娘百科中文歌声合成编辑团队的部分成员搭建,专属于中文歌声合成相关内容的信息集成站点 [^1],呈现形式为传统维基(基于 [MediaWiki](https://www.mediawiki.org/))。
-- [VocaDB](https://vocadb.net/): 一个围绕 Vocaloid、UTAU 和其他歌声合成器的协作数据库,其中包含艺术家、唱片、PV
- 等 [^2],其中包含大量中文歌声合成作品。
-- [天钿 Daily](https://tdd.bunnyxt.com/):一个 VC 相关数据交流与分享的网站。致力于 VC 相关数据交流,定期抓取 VC 相关数据,选取有意义的纬度展示。[^3]
-
-上述网站中,或多或少存在一些不足,例如:
-
-- 萌娘百科、VCPedia 受限于传统维基,绝大多数内容依赖人工编辑。
-- VocaDB 基于结构化数据库构建,由此可以依赖程序生成一些信息,但 **条目收录** 仍然完全依赖人工完成。
-- VocaDB 主要专注于元数据展示,少有关于歌曲、作者等的描述性的文字,也缺乏描述性的背景信息。
-- 天钿 Daily 只展示歌曲的统计数据及历史趋势,没有关于歌曲其它信息的收集。
-
-因此,**中 V 档案馆** 吸取前人经验,克服上述网站的不足,希望做到:
-
-- 歌曲收录(指发现歌曲并创建条目)的完全自动化
-- 歌曲元信息提取的高度自动化
-- 歌曲统计数据收集的完全自动化
-- 在程序辅助的同时欢迎并鼓励贡献者参与编辑(主要为描述性内容)或纠错
-- 在适当的许可声明下,引用来自上述源的数据,使内容更加全面、丰富。
-
-## 技术架构
-
-参见 [CVSA 文档](https://docs.projectcvsa.com/)。
-
-## 开放许可
-
-受本文以 [CC BY-NC-SA 4.0 协议](https://creativecommons.org/licenses/by-nc-sa/4.0/) 提供。
-
-### 数据库
-
-中 V 档案馆使用 [PostgreSQL](https://postgresql.org) 作为数据库,我们承诺定期导出数据库转储 (dump)
-文件并公开,其内容遵从以下协议或条款:
-
-- 数据库中的事实性数据,根据适用法律,不构成受版权保护的内容。中 V 档案馆放弃一切可能的权利([CC0 1.0 Universal](https://creativecommons.org/publicdomain/zero/1.0/))。
-- 对于数据库中有原创性的内容(如贡献者编辑的描述性内容),如无例外,以 [CC BY 4.0 协议](https://creativecommons.org/licenses/by/4.0/) 提供。
-- 对于引用、摘编或改编自萌娘百科、VCPedia 的内容,以与原始协议(CC BY-NC-SA 3.0
- CN)兼容的协议 [CC BY-NC-SA 4.0 协议](https://creativecommons.org/licenses/by-nc-sa/4.0/) 提供,并注明原始协议 。
- > 根据原始协议第四条第 2 项内容,CC BY-NC-SA 4.0 协议为与原始协议具有相同授权要素的后续版本(“可适用的协议”)。
-- 中 V 档案馆文档使用 [CC BY 4.0 协议](https://creativecommons.org/licenses/by/4.0/)。
-
-### 软件代码
-
-用于构建中 V 档案馆的软件代码在 [AGPL 3.0](https://www.gnu.org/licenses/agpl-3.0.html) 许可证下公开,参见 [LICENSE](./LICENSE)
-
-[^1]: 引用自 [VCPedia](https://vcpedia.cn/%E9%A6%96%E9%A1%B5),于 [知识共享 署名-非商业性使用-相同方式共享 3.0中国大陆 (CC BY-NC-SA 3.0 CN) 许可协议](https://creativecommons.org/licenses/by-nc-sa/3.0/cn/) 下提供。
-
-[^2]: 翻译自 [VocaDB](https://vocadb.net/),于 [CC BY 4.0协议](https://creativecommons.org/licenses/by/4.0/) 下提供。
-
-[^3]: 引用自 [关于 - 天钿Daily](https://tdd.bunnyxt.com/about)
diff --git a/packages/next/lib/mdx/SafeMDX.tsx b/packages/next/lib/mdx/SafeMDX.tsx
new file mode 100644
index 0000000..e3d9fc2
--- /dev/null
+++ b/packages/next/lib/mdx/SafeMDX.tsx
@@ -0,0 +1,982 @@
+import React, { cloneElement } from "react";
+import { htmlToJsx } from "html-to-jsx-transform";
+import { Node, Parent, RootContent } from "mdast";
+import remarkFrontmatter from "remark-frontmatter";
+import "./footnoteHelper";
+
+import { collapseWhiteSpace } from "collapse-white-space";
+import remarkComment from "remark-comment";
+import { visit } from "unist-util-visit";
+
+import { Root } from "mdast";
+import { MdxJsxFlowElement, MdxJsxTextElement } from "mdast-util-mdx-jsx";
+import { remark } from "remark";
+import remarkGfm from "remark-gfm";
+import remarkMdx from "remark-mdx";
+
+import { Fragment, ReactNode } from "react";
+
+type MyRootContent = RootContent | Root;
+
+export function mdxParse(code: string) {
+ const file = mdxProcessor.processSync(code);
+ return file.data.ast as Root;
+}
+
+declare module "mdast" {
+ export interface Data {
+ hProperties?: {
+ id?: string;
+ className?: string;
+ };
+ }
+}
+
+export type CustomTransformer = (
+ node: MyRootContent,
+ transform: (node: MyRootContent) => ReactNode
+) => ReactNode | undefined;
+
+export function SafeMdxRenderer({
+ components,
+ code = "",
+ mdast = null as any,
+ customTransformer
+}: {
+ components?: ComponentsMap;
+ code?: string;
+ mdast?: MyRootContent;
+ customTransformer?: CustomTransformer;
+}) {
+ const visitor = new MdastToJsx({
+ code,
+ mdast,
+ components,
+ customTransformer
+ });
+ const result = visitor.run();
+ return result;
+}
+
+export class MdastToJsx {
+ mdast: MyRootContent;
+ str: string;
+ jsxStr: string = "";
+ c: ComponentsMap;
+ errors: { message: string }[] = [];
+ customTransformer?: CustomTransformer;
+
+ constructor({
+ code = "",
+ mdast = undefined as any,
+ components = {} as ComponentsMap,
+ customTransformer
+ }: {
+ code?: string;
+ mdast?: MyRootContent;
+ components?: ComponentsMap;
+ customTransformer?: (
+ node: MyRootContent,
+ transform: (node: MyRootContent) => ReactNode
+ ) => ReactNode | undefined;
+ }) {
+ this.str = code;
+ this.mdast = mdast || mdxParse(code);
+ this.customTransformer = customTransformer;
+
+ this.c = {
+ ...Object.fromEntries(
+ nativeTags.map((tag) => {
+ return [tag, tag];
+ })
+ ),
+ ...components
+ };
+ }
+ mapMdastChildren(node: any) {
+ const res = node.children?.flatMap((child: MyRootContent) => this.mdastTransformer(child)).filter(Boolean);
+ if (Array.isArray(res)) {
+ if (!res.length) {
+ return null;
+ } else if (res.length === 1) {
+ return res[0];
+ } else {
+ return res.map((x, i) => (React.isValidElement(x) ? cloneElement(x, { key: i }) : x));
+ }
+ }
+ return res || null;
+ }
+ mapJsxChildren(node: any) {
+ const res = node.children?.flatMap((child: MyRootContent) => this.jsxTransformer(child)).filter(Boolean);
+ if (Array.isArray(res)) {
+ if (!res.length) {
+ return null;
+ } else if (res.length === 1) {
+ return res[0];
+ } else {
+ return res.map((x, i) => (React.isValidElement(x) ? cloneElement(x, { key: i }) : x));
+ }
+ }
+ return res || null;
+ }
+ jsxTransformer(node: MyRootContent): ReactNode {
+ if (!node) {
+ return [];
+ }
+
+ switch (node.type) {
+ case "mdxJsxTextElement":
+ case "mdxJsxFlowElement": {
+ if (!node.name) {
+ return [];
+ }
+
+ const Component = accessWithDot(this.c, node.name);
+
+ if (!Component) {
+ this.errors.push({
+ message: `Unsupported jsx component ${node.name}`
+ });
+ return null;
+ }
+
+ let attrsList = getJsxAttrs(node, (err) => {
+ this.errors.push(err);
+ });
+
+ let attrs = Object.fromEntries(attrsList);
+
+ function cssStringToObject(css: string): Record {
+ const styleObj: Record = {};
+ css.split(";").forEach((rule) => {
+ const [key, val] = rule.trim().split(":");
+ if (key && val) {
+ styleObj[key.trim()] = val.trim();
+ }
+ });
+ return styleObj;
+ }
+
+ const SafeComponent: React.FC = ({ style, ...props }) => {
+ const safeStyle = React.useMemo(() => {
+ if (typeof style === "string") {
+ return cssStringToObject(style);
+ }
+ return style || {};
+ }, [style]);
+
+ return ;
+ };
+ return {this.mapJsxChildren(node)};
+ }
+ default: {
+ return this.mdastTransformer(node);
+ }
+ }
+ }
+
+ run() {
+ const res = this.mdastTransformer(this.mdast) as ReactNode;
+ if (Array.isArray(res) && res.length === 1) {
+ return res[0];
+ }
+ return res;
+ }
+
+ mdastTransformer(node: MyRootContent): ReactNode {
+ if (!node) {
+ return [];
+ }
+
+ // Check for custom transformer first, giving it higher priority
+ if (this.customTransformer) {
+ const customResult = this.customTransformer(node, this.mdastTransformer.bind(this));
+ if (customResult !== undefined) {
+ return customResult;
+ }
+ }
+
+ switch (node.type) {
+ case "mdxjsEsm": {
+ // const start = node.position?.start?.offset;
+ // const end = node.position?.end?.offset;
+ // let text = this.str.slice(start, end);
+
+ return [];
+ }
+ case "mdxJsxTextElement":
+ case "mdxJsxFlowElement": {
+ const start = node.position?.start?.offset;
+ const end = node.position?.end?.offset;
+ const text = this.str.slice(start, end);
+
+ // XSS protection
+ if (node.name === "script") {
+ return [];
+ }
+
+ try {
+ this.jsxStr = text;
+ const result = this.jsxTransformer(node);
+ if (Array.isArray(result)) {
+ console.log(`Unexpected array result`);
+ } else if (result) {
+ return result;
+ }
+ } finally {
+ this.jsxStr = "";
+ }
+ return [];
+ }
+
+ case "mdxFlowExpression":
+ case "mdxTextExpression": {
+ if (!node.value) {
+ return [];
+ }
+ return [];
+ }
+ case "yaml": {
+ if (!node.value) {
+ return [];
+ }
+ return [];
+ }
+ case "heading": {
+ const level = node.depth;
+ const Tag = this.c[`h${level}`] ?? `h${level}`;
+
+ return {this.mapMdastChildren(node)};
+ }
+ case "paragraph": {
+ return {this.mapMdastChildren(node)};
+ }
+ case "blockquote": {
+ return {this.mapMdastChildren(node)};
+ }
+ case "thematicBreak": {
+ return ;
+ }
+ case "code": {
+ if (!node.value) {
+ return [];
+ }
+ const language = node.lang || "";
+ const code = node.value;
+ const codeBlock = (className?: string) => (
+
+ {code}
+
+ );
+
+ if (language) {
+ if (supportedLanguagesSet.has(language as (typeof supportedLanguages)[number])) {
+ return codeBlock(`language-${language}`);
+ } else {
+ this.errors.push({
+ message: `Unsupported language ${language}`
+ });
+ return codeBlock();
+ }
+ }
+ return codeBlock();
+ }
+
+ case "list": {
+ if (node.ordered) {
+ return (
+
+ {this.mapMdastChildren(node)}
+
+ );
+ }
+ return {this.mapMdastChildren(node)};
+ }
+ case "listItem": {
+ // https://github.com/syntax-tree/mdast-util-gfm-task-list-item#syntax-tree
+ if (node?.checked != null) {
+ return (
+
+ {this.mapMdastChildren(node)}
+
+ );
+ }
+ return {this.mapMdastChildren(node)};
+ }
+ case "text": {
+ if (!node.value) {
+ return [];
+ }
+ return node.value;
+ }
+ case "image": {
+ const src = node.url || "";
+ const alt = node.alt || "";
+ const title = node.title || "";
+ return ;
+ }
+ case "link": {
+ const href = node.url || "";
+ const title = node.title || "";
+ return (
+
+ {this.mapMdastChildren(node)}
+
+ );
+ }
+ case "strong": {
+ return {this.mapMdastChildren(node)};
+ }
+ case "emphasis": {
+ return {this.mapMdastChildren(node)};
+ }
+ case "delete": {
+ return {this.mapMdastChildren(node)};
+ }
+ case "inlineCode": {
+ if (!node.value) {
+ return [];
+ }
+ return {node.value};
+ }
+ case "break": {
+ return ;
+ }
+ case "root": {
+ return {this.mapMdastChildren(node)};
+ }
+ case "table": {
+ const [head, ...body] = React.Children.toArray(this.mapMdastChildren(node));
+ return (
+
+ {head && {head}}
+ {!!body?.length && {body}}
+
+ );
+ }
+ case "tableRow": {
+ return (
+
+ {this.mapMdastChildren(node)}
+
+ );
+ }
+ case "tableCell": {
+ let content = this.mapMdastChildren(node);
+ return (
+
+ {content}
+
+ );
+ }
+ case "definition": {
+ return [];
+ }
+ case "linkReference": {
+ let href = "";
+ mdastBfs(this.mdast, (child: any) => {
+ if (child.type === "definition" && child.identifier === node.identifier) {
+ href = child.url;
+ }
+ });
+
+ return (
+
+ {this.mapMdastChildren(node)}
+
+ );
+ }
+ case "footnoteReference": {
+ return (
+
+
+ {node.label}
+
+
+ );
+ }
+
+ case "footnoteDefinition": {
+ let content = this.mapMdastChildren(node);
+ return (
+
+ {content}
+
+ );
+ }
+
+ case "html": {
+ const start = node.position?.start?.offset;
+ const end = node.position?.end?.offset;
+ const text = this.str.slice(start, end);
+ if (!text) {
+ return [];
+ }
+
+ const jsx = htmlToJsx(text);
+ try {
+ this.jsxStr = jsx;
+ const result = this.jsxTransformer(node);
+ if (Array.isArray(result)) {
+ console.log(`Unexpected array result`);
+ } else if (result) {
+ return result;
+ }
+ } finally {
+ this.jsxStr = "";
+ }
+
+ return [];
+ }
+ case "imageReference": {
+ return [];
+ }
+
+ default: {
+ mdastBfs(node, (node) => {
+ delete node.position;
+ });
+
+ throw new Error(`cannot convert node` + JSON.stringify(node, null, 2));
+ }
+ }
+ }
+}
+
+export function getJsxAttrs(
+ node: MdxJsxFlowElement | MdxJsxTextElement,
+ onError: (err: { message: string }) => void = console.error
+) {
+ let attrsList = node.attributes
+ .map((attr) => {
+ if (attr.type === "mdxJsxExpressionAttribute") {
+ onError({
+ message: `Expressions in jsx props are not supported (${attr.value
+ .replace(/\n+/g, " ")
+ .replace(/ +/g, " ")})`
+ });
+ return;
+ }
+ if (attr.type !== "mdxJsxAttribute") {
+ throw new Error(`non mdxJsxAttribute is not supported: ${attr}`);
+ }
+
+ const v = attr.value;
+ if (typeof v === "string" || typeof v === "number") {
+ return [attr.name, v];
+ }
+ if (v === null) {
+ return [attr.name, true];
+ }
+ if (v?.type === "mdxJsxAttributeValueExpression") {
+ if (v.value === "true") {
+ return [attr.name, true];
+ }
+ if (v.value === "false") {
+ return [attr.name, false];
+ }
+ if (v.value === "null") {
+ return [attr.name, null];
+ }
+ if (v.value === "undefined") {
+ return [attr.name, undefined];
+ }
+ let quote = ['"', "'", "`"].find((q) => v.value.startsWith(q) && v.value.endsWith(q));
+ if (quote) {
+ let value = v.value;
+ if (quote !== '"') {
+ value = v.value.replace(new RegExp(quote, "g"), '"');
+ }
+ return [attr.name, JSON.parse(value)];
+ }
+
+ const number = Number(v.value);
+ if (!isNaN(number)) {
+ return [attr.name, number];
+ }
+ const parsedJson = safeJsonParse(v.value);
+ if (parsedJson) {
+ return [attr.name, parsedJson];
+ }
+
+ onError({
+ message: `Expressions in jsx props are not supported (${attr.name}={${v.value}})`
+ });
+ } else {
+ console.log("unhandled attr", { attr }, attr.type);
+ }
+
+ return;
+ })
+ .filter(isTruthy) as [string, any][];
+ return attrsList;
+}
+
+function isTruthy(val: T | undefined | null | false): val is T {
+ return Boolean(val);
+}
+
+function accessWithDot(obj: any, path: string) {
+ return path
+ .split(".")
+ .map((x) => x.trim())
+ .filter(Boolean)
+ .reduce((o, i) => o[i], obj);
+}
+
+export function mdastBfs(node: Parent | Node, cb?: (node: Node | Parent) => any) {
+ const queue = [node];
+ const result: any[] = [];
+ while (queue.length) {
+ const node = queue.shift();
+ let r = cb && node ? cb(node) : node;
+ if (Array.isArray(r)) {
+ queue.push(...r);
+ } else if (r) {
+ result.push(r);
+ }
+ if (node && "children" in node && node.children) {
+ queue.push(...(node.children as any));
+ }
+ }
+ return result;
+}
+
+function safeJsonParse(str: string) {
+ try {
+ return JSON.parse(str);
+ } catch (err) {
+ return null;
+ }
+}
+
+const nativeTags = [
+ "blockquote",
+ "strong",
+ "em",
+ "del",
+ "hr",
+ "a",
+ "b",
+ "br",
+ "button",
+ "div",
+ "form",
+ "h1",
+ "h2",
+ "h3",
+ "h4",
+ "head",
+ "iframe",
+ "img",
+ "input",
+ "label",
+ "li",
+ "link",
+ "ol",
+ "p",
+ "path",
+ "picture",
+ "script",
+ "section",
+ "source",
+ "span",
+ "sub",
+ "sup",
+ "svg",
+ "table",
+ "tbody",
+ "td",
+ "tfoot",
+ "th",
+ "thead",
+ "tr",
+ "ul",
+ "video",
+ "code",
+ "pre",
+ "figure",
+ "canvas",
+ "details",
+ "dl",
+ "dt",
+ "dd",
+ "fieldset",
+ "footer",
+ "header",
+ "legend",
+ "main",
+ "mark",
+ "nav",
+ "progress",
+ "summary",
+ "time"
+] as const;
+
+const supportedLanguages = [
+ "abap",
+ "abnf",
+ "actionscript",
+ "ada",
+ "agda",
+ "al",
+ "antlr4",
+ "apacheconf",
+ "apex",
+ "apl",
+ "applescript",
+ "aql",
+ "arduino",
+ "arff",
+ "asciidoc",
+ "asm6502",
+ "asmatmel",
+ "aspnet",
+ "autohotkey",
+ "autoit",
+ "avisynth",
+ "avro-idl",
+ "bash",
+ "basic",
+ "batch",
+ "bbcode",
+ "bicep",
+ "birb",
+ "bison",
+ "bnf",
+ "brainfuck",
+ "brightscript",
+ "bro",
+ "bsl",
+ "c",
+ "cfscript",
+ "chaiscript",
+ "cil",
+ "clike",
+ "clojure",
+ "cmake",
+ "cobol",
+ "coffeescript",
+ "concurnas",
+ "coq",
+ "cpp",
+ "crystal",
+ "csharp",
+ "cshtml",
+ "csp",
+ "css-extras",
+ "css",
+ "csv",
+ "cypher",
+ "d",
+ "dart",
+ "dataweave",
+ "dax",
+ "dhall",
+ "diff",
+ "django",
+ "dns-zone-file",
+ "docker",
+ "dot",
+ "ebnf",
+ "editorconfig",
+ "eiffel",
+ "ejs",
+ "elixir",
+ "elm",
+ "erb",
+ "erlang",
+ "etlua",
+ "excel-formula",
+ "factor",
+ "false",
+ "firestore-security-rules",
+ "flow",
+ "fortran",
+ "fsharp",
+ "ftl",
+ "gap",
+ "gcode",
+ "gdscript",
+ "gedcom",
+ "gherkin",
+ "git",
+ "glsl",
+ "gml",
+ "gn",
+ "go-module",
+ "go",
+ "graphql",
+ "groovy",
+ "haml",
+ "handlebars",
+ "haskell",
+ "haxe",
+ "hcl",
+ "hlsl",
+ "hoon",
+ "hpkp",
+ "hsts",
+ "http",
+ "ichigojam",
+ "icon",
+ "icu-message-format",
+ "idris",
+ "iecst",
+ "ignore",
+ "inform7",
+ "ini",
+ "io",
+ "j",
+ "java",
+ "javadoc",
+ "javadoclike",
+ "javascript",
+ "javastacktrace",
+ "jexl",
+ "jolie",
+ "jq",
+ "js-extras",
+ "js-templates",
+ "jsdoc",
+ "json",
+ "json5",
+ "jsonp",
+ "jsstacktrace",
+ "jsx",
+ "julia",
+ "keepalived",
+ "keyman",
+ "kotlin",
+ "kumir",
+ "kusto",
+ "latex",
+ "latte",
+ "less",
+ "lilypond",
+ "liquid",
+ "lisp",
+ "livescript",
+ "llvm",
+ "log",
+ "lolcode",
+ "lua",
+ "magma",
+ "makefile",
+ "markdown",
+ "markup-templating",
+ "markup",
+ "matlab",
+ "maxscript",
+ "mel",
+ "mermaid",
+ "mizar",
+ "mongodb",
+ "monkey",
+ "moonscript",
+ "n1ql",
+ "n4js",
+ "nand2tetris-hdl",
+ "naniscript",
+ "nasm",
+ "neon",
+ "nevod",
+ "nginx",
+ "nim",
+ "nix",
+ "nsis",
+ "objectivec",
+ "ocaml",
+ "opencl",
+ "openqasm",
+ "oz",
+ "parigp",
+ "parser",
+ "pascal",
+ "pascaligo",
+ "pcaxis",
+ "peoplecode",
+ "perl",
+ "php-extras",
+ "php",
+ "phpdoc",
+ "plsql",
+ "powerquery",
+ "powershell",
+ "processing",
+ "prolog",
+ "promql",
+ "properties",
+ "protobuf",
+ "psl",
+ "pug",
+ "puppet",
+ "pure",
+ "purebasic",
+ "purescript",
+ "python",
+ "q",
+ "qml",
+ "qore",
+ "qsharp",
+ "r",
+ "racket",
+ "reason",
+ "regex",
+ "rego",
+ "renpy",
+ "rest",
+ "rip",
+ "roboconf",
+ "robotframework",
+ "ruby",
+ "rust",
+ "sas",
+ "sass",
+ "scala",
+ "scheme",
+ "scss",
+ "shell-session",
+ "smali",
+ "smalltalk",
+ "smarty",
+ "sml",
+ "solidity",
+ "solution-file",
+ "soy",
+ "sparql",
+ "splunk-spl",
+ "sqf",
+ "sql",
+ "squirrel",
+ "stan",
+ "stylus",
+ "swift",
+ "systemd",
+ "t4-cs",
+ "t4-templating",
+ "t4-vb",
+ "tap",
+ "tcl",
+ "textile",
+ "toml",
+ "tremor",
+ "tsx",
+ "tt2",
+ "turtle",
+ "twig",
+ "typescript",
+ "typoscript",
+ "unrealscript",
+ "uorazor",
+ "uri",
+ "v",
+ "vala",
+ "vbnet",
+ "velocity",
+ "verilog",
+ "vhdl",
+ "vim",
+ "visual-basic",
+ "warpscript",
+ "wasm",
+ "web-idl",
+ "wiki",
+ "wolfram",
+ "wren",
+ "xeora",
+ "xml-doc",
+ "xojo",
+ "xquery",
+ "yaml",
+ "yang",
+ "zig"
+] as const;
+const supportedLanguagesSet = new Set(supportedLanguages);
+
+type ComponentsMap = { [k in (typeof nativeTags)[number]]?: any } & {
+ [key: string]: any;
+};
+
+/**
+ * https://github.com/mdx-js/mdx/blob/b3351fadcb6f78833a72757b7135dcfb8ab646fe/packages/mdx/lib/plugin/remark-mark-and-unravel.js
+ * A tiny plugin that unravels `x
` but also
+ * `
` (so it has no knowledge of "HTML").
+ *
+ * It also marks JSX as being explicitly JSX, so when a user passes a `h1`
+ * component, it is used for `# heading` but not for `heading
`.
+ *
+ */
+export function remarkMarkAndUnravel() {
+ return function (tree: Root) {
+ visit(tree, function (node, index, parent) {
+ let offset = -1;
+ let all = true;
+ let oneOrMore = false;
+
+ if (parent && typeof index === "number" && node.type === "paragraph") {
+ const children = node.children;
+
+ while (++offset < children.length) {
+ const child = children[offset];
+
+ if (child.type === "mdxJsxTextElement" || child.type === "mdxTextExpression") {
+ oneOrMore = true;
+ } else if (
+ child.type === "text" &&
+ collapseWhiteSpace(child.value, {
+ style: "html",
+ trim: true
+ }) === ""
+ ) {
+ // Empty.
+ } else {
+ all = false;
+ break;
+ }
+ }
+
+ if (all && oneOrMore) {
+ offset = -1;
+
+ const newChildren: RootContent[] = [];
+
+ while (++offset < children.length) {
+ const child = children[offset];
+
+ if (child.type === "mdxJsxTextElement") {
+ // @ts-expect-error: mutate because it is faster; content model is fine.
+ child.type = "mdxJsxFlowElement";
+ }
+
+ if (child.type === "mdxTextExpression") {
+ // @ts-expect-error: mutate because it is faster; content model is fine.
+ child.type = "mdxFlowExpression";
+ }
+
+ if (child.type === "text" && /^[\t\r\n ]+$/.test(String(child.value))) {
+ // Empty.
+ } else {
+ newChildren.push(child);
+ }
+ }
+
+ parent.children.splice(index, 1, ...newChildren);
+ return index;
+ }
+ }
+ });
+ };
+}
+
+const mdxProcessor = remark()
+ .use(remarkMdx)
+ .use(remarkComment)
+ .use(remarkFrontmatter, ["yaml", "toml"])
+ .use(remarkGfm)
+ .use(remarkMarkAndUnravel)
+ .use(() => {
+ return (tree, file) => {
+ file.data.ast = tree;
+ };
+ });
diff --git a/packages/next/lib/mdx/footnoteHelper.tsx b/packages/next/lib/mdx/footnoteHelper.tsx
new file mode 100644
index 0000000..7967612
--- /dev/null
+++ b/packages/next/lib/mdx/footnoteHelper.tsx
@@ -0,0 +1,63 @@
+import { Root, FootnoteDefinition, Heading, List, ListItem } from "mdast";
+import { Plugin } from "unified";
+const remarkCollectFootnotes: Plugin<[], Root> = function () {
+ return function transformer(tree) {
+ const footnotes: FootnoteDefinition[] = [];
+
+ // 收集所有 footnoteDefinition 并从树中移除它们
+ tree.children = tree.children.filter((node) => {
+ if (node.type === "footnoteDefinition") {
+ footnotes.push(node as FootnoteDefinition);
+ return false;
+ }
+ return true;
+ });
+
+ if (footnotes.length === 0) return tree;
+
+ const heading: Heading = {
+ type: "heading",
+ depth: 2,
+ children: [{ type: "text", value: "脚注" }]
+ };
+
+ const list: List = {
+ type: "list",
+ ordered: true,
+ start: 1,
+ children: footnotes.map((def, i) => {
+ return {
+ type: "listItem",
+ children: [
+ ...(def.children || []).flatMap((child) => {
+ return "value" in child ? { type: "text", value: child.value } : child;
+ }),
+ {
+ type: "link",
+ url: `#user-content-fnref-${i + 1}`,
+ children: [
+ {
+ type: "text",
+ value: " ↩"
+ }
+ ]
+ }
+ ],
+ data: {
+ hProperties: {
+ id: `user-content-fn-${i + 1}`,
+ className: "footnote-li"
+ }
+ }
+ } as ListItem;
+ })
+ };
+
+ // 添加到文档最后
+ tree.children.push(heading, list);
+
+ return tree;
+ };
+};
+
+export default remarkCollectFootnotes;
diff --git a/packages/next/lib/state/background.ts b/packages/next/lib/state/background.ts
new file mode 100644
index 0000000..8ad3599
--- /dev/null
+++ b/packages/next/lib/state/background.ts
@@ -0,0 +1,3 @@
+import { atom } from "jotai";
+
+export const backgroundURLAtom = atom(null);
diff --git a/packages/next/next.config.ts b/packages/next/next.config.ts
index 4236ee8..bc3a70f 100644
--- a/packages/next/next.config.ts
+++ b/packages/next/next.config.ts
@@ -1,6 +1,5 @@
import type { NextConfig } from "next";
import createNextIntlPlugin from "next-intl/plugin";
-import { createMDX } from "fumadocs-mdx/next";
const nextConfig: NextConfig = {
devIndicators: false,
@@ -22,11 +21,9 @@ const nextConfig: NextConfig = {
});
return config;
},
- pageExtensions: ["js", "jsx", "md", "mdx", "ts", "tsx"]
+ transpilePackages: ["next-mdx-remote"]
};
const withNextIntl = createNextIntlPlugin();
-const withMDX = createMDX();
-
-export default withNextIntl(withMDX(nextConfig));
+export default withNextIntl(nextConfig);
diff --git a/packages/next/package.json b/packages/next/package.json
index 59dfc18..460a16c 100644
--- a/packages/next/package.json
+++ b/packages/next/package.json
@@ -24,11 +24,21 @@
"jotai": "^2.12.5",
"next": "^15.3.3",
"next-intl": "^4.1.0",
+ "next-mdx-remote": "^5.0.0",
"raw-loader": "^4.0.2",
"react": "^19.1.0",
"react-dom": "^19.1.0",
+ "rehype-sanitize": "^6.0.0",
+ "rehype-stringify": "^10.0.1",
+ "remark-comment": "^1.0.0",
+ "remark-comments": "^1.2.10",
+ "remark-parse": "^11.0.0",
+ "remark-rehype": "^11.1.2",
+ "remark-remove-comments": "^1.1.1",
+ "safe-mdx": "^0.1.0",
"swr": "^2.3.3",
"ua-parser-js": "^2.0.3",
+ "unified": "^11.0.5",
"yup": "^1.6.1",
"yup-numeric": "^0.5.0"
},
diff --git a/packages/next/source.config.ts b/packages/next/source.config.ts
deleted file mode 100644
index e74c9d9..0000000
--- a/packages/next/source.config.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-import { defineConfig } from "fumadocs-mdx/config";
-
-export default defineConfig({
- mdxOptions: {}
-});