From 02ae938c238c9d18448d17a8ec92c0edd8c17463 Mon Sep 17 00:00:00 2001 From: Bertrand Yuan Date: Tue, 16 Dec 2025 00:12:49 +0800 Subject: feat(back-end): introduce payload Payload is the next.js Headless CMS and App Framework, I would like to pick it up and modify it as it is MIT licensed. Many features in Payload is not applicable for our project. So, I modify it so that it is light and clear. --- src/components/docs.tsx | 2 +- src/components/json-ld.tsx | 4 +- src/components/mdx-layout.tsx | 2 +- src/components/newsletter-form.tsx | 2 +- src/components/rich-text/index.tsx | 42 +++++++ src/components/rich-text/node-format.ts | 11 ++ src/components/rich-text/serialize.tsx | 201 ++++++++++++++++++++++++++++++ src/components/sections/footer.tsx | 2 +- src/components/sections/header/menu.tsx | 2 +- src/components/sections/header/navbar.tsx | 2 +- 10 files changed, 262 insertions(+), 8 deletions(-) create mode 100644 src/components/rich-text/index.tsx create mode 100644 src/components/rich-text/node-format.ts create mode 100644 src/components/rich-text/serialize.tsx (limited to 'src/components') diff --git a/src/components/docs.tsx b/src/components/docs.tsx index 038a52b..0dec2dd 100644 --- a/src/components/docs.tsx +++ b/src/components/docs.tsx @@ -1,5 +1,5 @@ import type { PageTree } from 'fumadocs-core/server'; -import { cn } from 'fumadocs-ui/components/api'; +import { cn } from '@/lib/utils'; import { type SidebarOptions, checkPageTree, diff --git a/src/components/json-ld.tsx b/src/components/json-ld.tsx index cd30274..a7cd884 100644 --- a/src/components/json-ld.tsx +++ b/src/components/json-ld.tsx @@ -1,5 +1,5 @@ -import { title as homeTitle } from '@/app/layout.config'; -import { owner } from '@/app/layout.config'; +import { title as homeTitle } from '@/app/(main)/layout.config'; +import { owner } from '@/app/(main)/layout.config'; import { baseUrl } from '@/lib/constants'; import type { Post } from '@/lib/source'; import type { BlogPosting, BreadcrumbList, Graph } from 'schema-dts'; diff --git a/src/components/mdx-layout.tsx b/src/components/mdx-layout.tsx index bc68a93..f984dfc 100644 --- a/src/components/mdx-layout.tsx +++ b/src/components/mdx-layout.tsx @@ -1,4 +1,4 @@ -import { PostComments } from '@/app/(home)/posts/[slug]/page.client'; +import { PostComments } from '@/app/(main)/(home)/posts/[slug]/page.client'; import type { TOCItemType } from 'fumadocs-core/server'; import { InlineTOC } from 'fumadocs-ui/components/inline-toc'; import type { ReactNode } from 'react'; diff --git a/src/components/newsletter-form.tsx b/src/components/newsletter-form.tsx index 7dd0cc0..83a9bee 100644 --- a/src/components/newsletter-form.tsx +++ b/src/components/newsletter-form.tsx @@ -18,7 +18,7 @@ import { useForm } from 'react-hook-form'; import { Alert, AlertTitle } from '@/components/ui/alert'; -import { subscribeUser } from '@/app/(home)/actions'; +import { subscribeUser } from '@/app/(main)/(home)/actions'; import { Icons } from '@/components/icons/icons'; export const NewsletterForm = () => { diff --git a/src/components/rich-text/index.tsx b/src/components/rich-text/index.tsx new file mode 100644 index 0000000..556942c --- /dev/null +++ b/src/components/rich-text/index.tsx @@ -0,0 +1,42 @@ +import { cn } from '@/lib/utils' +import React from 'react' +import { serializeLexical } from './serialize' + +type Props = { + className?: string + content: Record + enableProse?: boolean +} + +export function RichText({ + className, + content, + enableProse = true, +}: Props) { + if (!content) { + return null + } + + return ( +
+ {content && + !Array.isArray(content) && + typeof content === 'object' && + 'root' in content && + serializeLexical({ + nodes: (content.root as { children: unknown[] })?.children as Parameters< + typeof serializeLexical + >[0]['nodes'], + })} +
+ ) +} + +export default RichText diff --git a/src/components/rich-text/node-format.ts b/src/components/rich-text/node-format.ts new file mode 100644 index 0000000..84e7d7e --- /dev/null +++ b/src/components/rich-text/node-format.ts @@ -0,0 +1,11 @@ +// From Lexical: https://github.com/facebook/lexical/blob/c2ceee223f46543d12c574e62155e619f9a18a5d/packages/lexical/src/LexicalConstants.ts + +// Text node formatting +export const IS_BOLD = 1 +export const IS_ITALIC = 1 << 1 +export const IS_STRIKETHROUGH = 1 << 2 +export const IS_UNDERLINE = 1 << 3 +export const IS_CODE = 1 << 4 +export const IS_SUBSCRIPT = 1 << 5 +export const IS_SUPERSCRIPT = 1 << 6 +export const IS_HIGHLIGHT = 1 << 7 diff --git a/src/components/rich-text/serialize.tsx b/src/components/rich-text/serialize.tsx new file mode 100644 index 0000000..d75c40f --- /dev/null +++ b/src/components/rich-text/serialize.tsx @@ -0,0 +1,201 @@ +import React, { Fragment, type JSX } from 'react' +import Link from 'next/link' +import { + IS_BOLD, + IS_CODE, + IS_ITALIC, + IS_STRIKETHROUGH, + IS_SUBSCRIPT, + IS_SUPERSCRIPT, + IS_UNDERLINE, +} from './node-format' + +// Lexical 节点类型 +interface LexicalNode { + type: string + format?: number + text?: string + tag?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'ul' | 'ol' + listType?: 'bullet' | 'number' | 'check' + checked?: boolean + value?: number + children?: LexicalNode[] + fields?: { + linkType?: 'internal' | 'custom' + url?: string + newTab?: boolean + doc?: { + value?: { + slug?: string + } + relationTo?: string + } + } + language?: string + version?: number +} + +type Props = { + nodes: LexicalNode[] +} + +export function serializeLexical({ nodes }: Props): JSX.Element { + return ( + + {nodes?.map((node, index): JSX.Element | null => { + if (node == null) { + return null + } + + if (node.type === 'text') { + let text = {node.text} + const format = node.format || 0 + + if (format & IS_BOLD) { + text = {text} + } + if (format & IS_ITALIC) { + text = {text} + } + if (format & IS_STRIKETHROUGH) { + text = ( + + {text} + + ) + } + if (format & IS_UNDERLINE) { + text = ( + + {text} + + ) + } + if (format & IS_CODE) { + text = {node.text} + } + if (format & IS_SUBSCRIPT) { + text = {text} + } + if (format & IS_SUPERSCRIPT) { + text = {text} + } + + return text + } + + // 处理子节点 + const serializedChildrenFn = (node: LexicalNode): JSX.Element | null => { + if (node.children == null) { + return null + } else { + // 处理 checkbox list + if (node?.type === 'list' && node?.listType === 'check') { + for (const item of node.children) { + if ('checked' in item) { + if (!item?.checked) { + item.checked = false + } + } + } + } + return serializeLexical({ nodes: node.children }) + } + } + + const serializedChildren = 'children' in node ? serializedChildrenFn(node) : '' + + switch (node.type) { + case 'linebreak': { + return
+ } + case 'paragraph': { + return

{serializedChildren}

+ } + case 'heading': { + const Tag = node?.tag || 'h2' + return {serializedChildren} + } + case 'list': { + const Tag = node?.tag || 'ul' + return {serializedChildren} + } + case 'listitem': { + if (node?.checked != null) { + return ( +
  • + + {serializedChildren} +
  • + ) + } else { + return ( +
  • + {serializedChildren} +
  • + ) + } + } + case 'quote': { + return
    {serializedChildren}
    + } + case 'link': { + const fields = node.fields + + if (fields?.linkType === 'internal' && fields?.doc?.value?.slug) { + const href = + fields.doc.relationTo === 'posts' + ? `/posts/${fields.doc.value.slug}` + : `/${fields.doc.value.slug}` + + return ( + + {serializedChildren} + + ) + } + + return ( + + {serializedChildren} + + ) + } + case 'code': { + // 代码块 + return ( +
    +                {serializedChildren}
    +              
    + ) + } + case 'horizontalrule': { + return
    + } + default: + // 如果有子节点,递归渲染 + if (node.children) { + return {serializedChildren} + } + return null + } + })} +
    + ) +} diff --git a/src/components/sections/footer.tsx b/src/components/sections/footer.tsx index 8d0d876..3a1b51d 100644 --- a/src/components/sections/footer.tsx +++ b/src/components/sections/footer.tsx @@ -1,4 +1,4 @@ -import { baseOptions, linkItems, postsPerPage } from '@/app/layout.config'; +import { baseOptions, linkItems, postsPerPage } from '@/app/(main)/layout.config'; import { InlineLink } from '@/components/inline-link'; import { getSortedByDatePosts, getTags } from '@/lib/source'; import { cn } from '@/lib/utils'; diff --git a/src/components/sections/header/menu.tsx b/src/components/sections/header/menu.tsx index 57ddf12..7eb6644 100644 --- a/src/components/sections/header/menu.tsx +++ b/src/components/sections/header/menu.tsx @@ -2,7 +2,7 @@ import { cva } from 'class-variance-authority'; import Link from 'fumadocs-core/link'; -import { cn } from 'fumadocs-ui/components/api'; +import { cn } from '@/lib/utils'; import { buttonVariants } from 'fumadocs-ui/components/ui/button'; import { NavigationMenuContent, diff --git a/src/components/sections/header/navbar.tsx b/src/components/sections/header/navbar.tsx index 25850a0..bac7d3d 100644 --- a/src/components/sections/header/navbar.tsx +++ b/src/components/sections/header/navbar.tsx @@ -1,7 +1,7 @@ 'use client'; import Link, { type LinkProps } from 'fumadocs-core/link'; -import { cn } from 'fumadocs-ui/components/api'; +import { cn } from '@/lib/utils'; import { NavigationMenu, NavigationMenuLink, -- cgit v1.2.3