├── .eslintrc.json ├── .npmrc ├── .env.production ├── public ├── logo.png ├── fonts │ └── SimSun.ttf ├── uploads │ └── .DS_Store ├── vercel.svg └── next.svg ├── src ├── app │ ├── favicon.ico │ ├── docs │ │ ├── page.tsx │ │ └── editProps.tsx │ ├── page.tsx │ ├── layout.tsx │ └── globals.css ├── lib │ ├── utils.ts │ └── useMounted.tsx ├── mdx-components.tsx ├── pages │ ├── api │ │ ├── hello.js │ │ ├── getImage.js │ │ ├── images │ │ │ └── [filename].ts │ │ ├── generatePoster.ts │ │ └── generatePosterImage.ts │ └── poster │ │ ├── layout.tsx │ │ └── index.tsx ├── components │ ├── Section.tsx │ ├── Footer.tsx │ ├── ui │ │ ├── label.tsx │ │ ├── textarea.tsx │ │ ├── switch.tsx │ │ ├── radio-group.tsx │ │ ├── scroll-area.tsx │ │ ├── button.tsx │ │ ├── card.tsx │ │ └── select.tsx │ ├── PosterView.tsx │ ├── Editor.tsx │ └── Header.tsx ├── styles │ └── header.css └── markdown │ ├── docs.mdx │ └── home.mdx ├── .env ├── example ├── output │ ├── news_poster.png │ ├── poster-1754641688124.png │ ├── poster-1754647673038.png │ ├── poster-1754647681361.png │ ├── poster-1754647689189.png │ └── poster-1754647978853.png ├── news_poster.md ├── api_buffer_2_image.js ├── api_request.json └── api_buffer_2_image.py ├── postcss.config.mjs ├── .gitignore ├── .dockerignore ├── next-env.d.ts ├── Dockerfile ├── .whitesource ├── .env.shadow ├── railway.json ├── components.json ├── docker-compose.prod.yml ├── render.yaml ├── Dockerfile.base ├── docker-compose.yml ├── patches └── markdown-to-poster+0.0.9.patch ├── tsconfig.json ├── next.config.mjs ├── fly.toml ├── LICENSE ├── package.json ├── dev.md ├── Dockerfile.backup ├── tailwind.config.ts ├── .github └── workflows │ ├── docker-image-no-cache.yml │ └── docker-image.yml ├── DOCKER_SETUP.md ├── DEPLOYMENT.md ├── TROUBLESHOOTING.md ├── README_EN.md └── README.md /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | # 设置阿里云镜像源 2 | registry=https://registry.npmmirror.com/ 3 | -------------------------------------------------------------------------------- /.env.production: -------------------------------------------------------------------------------- 1 | CHROME_PATH=/opt/bin/chromium 2 | NEXT_PUBLIC_BASE_URL=http://localhost:3000 -------------------------------------------------------------------------------- /public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wxingheng/markdown-to-image-serve/HEAD/public/logo.png -------------------------------------------------------------------------------- /src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wxingheng/markdown-to-image-serve/HEAD/src/app/favicon.ico -------------------------------------------------------------------------------- /public/fonts/SimSun.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wxingheng/markdown-to-image-serve/HEAD/public/fonts/SimSun.ttf -------------------------------------------------------------------------------- /public/uploads/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wxingheng/markdown-to-image-serve/HEAD/public/uploads/.DS_Store -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_BASE_URL=http://localhost:3000 2 | CHROME_PATH=/Applications/Google Chrome.app/Contents/MacOS/Google Chrome -------------------------------------------------------------------------------- /example/output/news_poster.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wxingheng/markdown-to-image-serve/HEAD/example/output/news_poster.png -------------------------------------------------------------------------------- /example/output/poster-1754641688124.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wxingheng/markdown-to-image-serve/HEAD/example/output/poster-1754641688124.png -------------------------------------------------------------------------------- /example/output/poster-1754647673038.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wxingheng/markdown-to-image-serve/HEAD/example/output/poster-1754647673038.png -------------------------------------------------------------------------------- /example/output/poster-1754647681361.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wxingheng/markdown-to-image-serve/HEAD/example/output/poster-1754647681361.png -------------------------------------------------------------------------------- /example/output/poster-1754647689189.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wxingheng/markdown-to-image-serve/HEAD/example/output/poster-1754647689189.png -------------------------------------------------------------------------------- /example/output/poster-1754647978853.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wxingheng/markdown-to-image-serve/HEAD/example/output/poster-1754647978853.png -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { type ClassValue, clsx } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /src/mdx-components.tsx: -------------------------------------------------------------------------------- 1 | import type { MDXComponents } from 'mdx/types' 2 | 3 | export function useMDXComponents(components: MDXComponents): MDXComponents { 4 | return { 5 | ...components, 6 | } 7 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # *.mdx 2 | # *.mdx.js 3 | # *.md.js 4 | node_modules 5 | .next 6 | # .env 7 | .env.development.local 8 | .env.test.local 9 | .env.production.local 10 | public/uploads/posters/* 11 | .env.local 12 | .vercel 13 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .next 3 | npm-debug.log 4 | yarn-debug.log 5 | yarn-error.log 6 | .git 7 | .gitignore 8 | .env 9 | .DS_Store 10 | README.md 11 | README_EN.md 12 | example 13 | dist 14 | build 15 | Dockerfile* 16 | .env* -------------------------------------------------------------------------------- /src/lib/useMounted.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useState, useEffect } from 'react' 4 | 5 | export function useMounted() { 6 | const [mounted, setMounted] = useState(false) 7 | 8 | useEffect(() => { 9 | setMounted(true) 10 | }, []) 11 | 12 | return mounted 13 | } -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | /// 4 | 5 | // NOTE: This file should not be edited 6 | // see https://nextjs.org/docs/basic-features/typescript for more information. 7 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # 文件:Dockerfile 2 | FROM wxingheng/node-chrome-base:latest 3 | 4 | # 设置工作目录 5 | WORKDIR /app 6 | 7 | # 复制依赖文件并安装 8 | COPY package*.json ./ 9 | COPY .env* ./ 10 | 11 | RUN npm install 12 | 13 | # 复制应用代码并构建 14 | COPY . . 15 | 16 | RUN npm run build 17 | 18 | EXPOSE 3000 19 | 20 | CMD ["npm", "start"] -------------------------------------------------------------------------------- /.whitesource: -------------------------------------------------------------------------------- 1 | { 2 | "scanSettings": { 3 | "baseBranches": [] 4 | }, 5 | "checkRunSettings": { 6 | "vulnerableCheckRunConclusionLevel": "failure", 7 | "displayMode": "diff", 8 | "useMendCheckNames": true 9 | }, 10 | "issueSettings": { 11 | "minSeverityLevel": "LOW", 12 | "issueType": "DEPENDENCY" 13 | } 14 | } -------------------------------------------------------------------------------- /.env.shadow: -------------------------------------------------------------------------------- 1 | ### 2 | # @Author: wxingheng 3 | # @Date: 2024-11-28 10:57:38 4 | # @LastEditTime: 2024-11-28 10:57:39 5 | # @LastEditors: wxingheng 6 | # @Description: 7 | # @FilePath: /markdown-to-image-serve/.env copy.local 8 | ### 9 | NEXT_PUBLIC_BASE_URL=http://localhost:3000 10 | CHROME_PATH=/Applications/Google Chrome.app/Contents/MacOS/Google Chrome -------------------------------------------------------------------------------- /src/pages/api/hello.js: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: wxingheng 3 | * @Date: 2024-11-27 16:52:33 4 | * @LastEditTime: 2024-11-27 16:52:35 5 | * @LastEditors: wxingheng 6 | * @Description: 7 | * @FilePath: /example/src/pages/api/hello.js 8 | */ 9 | export default function handler(req, res) { 10 | res.status(200).json({ message: 'Hello, Next.js API!' }); 11 | } -------------------------------------------------------------------------------- /src/pages/api/getImage.js: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: wxingheng 3 | * @Date: 2024-11-27 16:55:05 4 | * @LastEditTime: 2024-11-27 16:55:06 5 | * @LastEditors: wxingheng 6 | * @Description: 7 | * @FilePath: /example/src/pages/api/getImage.js 8 | */ 9 | 10 | export default function handler(req, res) { 11 | res.status(200).json({ message: "Hello, Next.js API! getImage" }); 12 | } 13 | -------------------------------------------------------------------------------- /railway.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://railway.app/railway.schema.json", 3 | "build": { 4 | "builder": "DOCKERFILE", 5 | "dockerfilePath": "Dockerfile" 6 | }, 7 | "deploy": { 8 | "startCommand": "yarn start", 9 | "healthcheckPath": "/api/hello", 10 | "healthcheckTimeout": 300, 11 | "restartPolicyType": "ON_FAILURE", 12 | "restartPolicyMaxRetries": 10 13 | } 14 | } -------------------------------------------------------------------------------- /src/components/Section.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | /** 3 | * 4 | * @param param0 5 | * @returns 6 | */ 7 | export default function Section({ children, className }: { children?: React.ReactNode | string; className?: string }) { 8 | return ( 9 |
10 |
{children}
11 |
12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /src/styles/header.css: -------------------------------------------------------------------------------- 1 | .friend-link-btn { 2 | display: flex; 3 | align-items: center; 4 | font-size: 16px; 5 | font-weight: 500; 6 | color: #1890ff; 7 | padding: 4px 12px; 8 | border-radius: 4px; 9 | transition: all 0.3s; 10 | } 11 | 12 | .friend-link-btn:hover { 13 | color: #40a9ff; 14 | background: rgba(24, 144, 255, 0.1); 15 | } 16 | 17 | .friend-link-btn span { 18 | margin-left: 4px; 19 | } -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "src/app/globals.css", 9 | "baseColor": "gray", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils" 16 | } 17 | } -------------------------------------------------------------------------------- /src/pages/poster/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Inter } from "next/font/google"; 2 | const inter = Inter({ subsets: ["latin"] }); 3 | import 'markdown-to-poster/dist/style.css' 4 | 5 | 6 | export default function PosterLayout({ 7 | children, 8 | }: Readonly<{ 9 | children: React.ReactNode; 10 | }>) { 11 | return ( 12 | 13 | 14 | {children} 15 | 16 | 17 | ); 18 | } -------------------------------------------------------------------------------- /src/app/docs/page.tsx: -------------------------------------------------------------------------------- 1 | import dynamic from 'next/dynamic' 2 | import MdDocs from '@/markdown/docs.mdx' 3 | import Section from '@/components/Section' 4 | 5 | const EditProps = dynamic(() => import('./editProps'), { 6 | ssr: false, 7 | }) 8 | 9 | export default function Docs() { 10 | return ( 11 |
12 |
13 | 14 |
15 | 16 |
17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /docker-compose.prod.yml: -------------------------------------------------------------------------------- 1 | version: '0.1' 2 | 3 | services: 4 | app: 5 | image: wxingheng/markdown-to-image-serve:0.0.1 6 | # 指定平台 amd64 架构 (Mac Apple Silicon 平台) 7 | platform: linux/amd64 8 | ports: 9 | - "3000:3000" 10 | environment: 11 | - NODE_ENV=production 12 | - NEXT_PUBLIC_BASE_URL=http://localhost:3000 13 | - CHROME_PATH=/usr/bin/google-chrome-unstable 14 | volumes: 15 | # 只挂载必要的目录,不挂载源代码 16 | - ./public/uploads/posters:/tmp/uploads/posters -------------------------------------------------------------------------------- /render.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | - type: web 3 | name: markdown-to-image-serve 4 | env: docker 5 | region: oregon 6 | plan: free 7 | dockerfilePath: ./Dockerfile 8 | dockerCommand: yarn start 9 | envVars: 10 | - key: NODE_ENV 11 | value: production 12 | - key: NEXT_PUBLIC_BASE_URL 13 | value: https://markdown-to-image-serve.onrender.com 14 | - key: CHROME_PATH 15 | value: /usr/bin/google-chrome-unstable 16 | healthCheckPath: /api/hello 17 | autoDeploy: true -------------------------------------------------------------------------------- /src/app/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | import MdHome from '@/markdown/home.mdx' 3 | import Section from '@/components/Section' 4 | import dynamic from 'next/dynamic' 5 | 6 | const Editor = dynamic(() => import('@/components/Editor'), { 7 | ssr: false, 8 | }) 9 | export default function Home() { 10 | return ( 11 |
12 |
13 |
14 |
15 | 16 |
17 |
18 |
19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /src/pages/api/images/[filename].ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from 'next'; 2 | import fs from 'fs'; 3 | import path from 'path'; 4 | 5 | export default function handler(req: NextApiRequest, res: NextApiResponse) { 6 | const { filename } = req.query; 7 | const filePath = path.join('/tmp', 'uploads', 'posters', filename as string); 8 | 9 | try { 10 | const imageBuffer = fs.readFileSync(filePath); 11 | res.setHeader('Content-Type', 'image/png'); 12 | res.send(imageBuffer); 13 | } catch (error) { 14 | res.status(404).json({ error: '图片未找到' }); 15 | } 16 | } -------------------------------------------------------------------------------- /src/pages/poster/index.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: wxingheng 3 | * @Date: 2024-11-27 17:50:50 4 | * @LastEditTime: 2025-07-09 15:48:21 5 | * @LastEditors: wxingheng 6 | * @Description: 7 | * @FilePath: /markdown-to-image-serve/src/pages/poster/index.tsx 8 | */ 9 | "use client"; 10 | import dynamic from "next/dynamic"; 11 | import "markdown-to-poster/dist/style.css"; 12 | 13 | // .bg-spring-gradient-wave 需要设置这个css 的样式 14 | 15 | const PosterView = dynamic(() => import("@/components/PosterView"), { 16 | ssr: false, 17 | }); 18 | export default function Home() { 19 | return ; 20 | } 21 | -------------------------------------------------------------------------------- /Dockerfile.base: -------------------------------------------------------------------------------- 1 | FROM node:20-slim 2 | 3 | # 设置国内 npm 镜像源 4 | RUN npm config set registry https://registry.npmmirror.com 5 | 6 | # 安装 Chromium 和中文字体(适用于 Puppeteer / Playwright) 7 | RUN apt-get update \ 8 | && apt-get install -y \ 9 | chromium \ 10 | fonts-ipafont-gothic \ 11 | fonts-wqy-zenhei \ 12 | fonts-thai-tlwg \ 13 | fonts-kacst \ 14 | fonts-freefont-ttf \ 15 | --no-install-recommends \ 16 | && rm -rf /var/lib/apt/lists/* 17 | 18 | # 设置 Puppeteer 的环境变量 19 | ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true 20 | ENV CHROME_PATH=/usr/bin/chromium 21 | 22 | # 设置工作目录 23 | WORKDIR /app -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '0.1' 2 | 3 | services: 4 | app: 5 | # 使用本地构建 6 | # build: 7 | # context: . 8 | # 使用已有的远程镜像 9 | image: wxingheng/markdown-to-image-serve:0.0.1 10 | platform: linux/amd64 11 | ports: 12 | - "3000:3000" 13 | environment: 14 | - NODE_ENV=production 15 | - NEXT_PUBLIC_BASE_URL=http://localhost:3000 16 | - CHROME_PATH=/usr/bin/google-chrome-unstable 17 | volumes: 18 | - .:/app 19 | - /app/node_modules 20 | # 指定缓存目录, 避免每次重新构建 21 | - /app/.next 22 | # 需要将容器中的文件挂载到宿主机 23 | - ./public/uploads/posters:/tmp/uploads/posters 24 | -------------------------------------------------------------------------------- /src/components/Footer.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link' 2 | 3 | export default function Footer() { 4 | return ( 5 |
10 |

14 | © 2025 15 | wxingheng/markdown-to-image-serve 16 |

17 |
18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /patches/markdown-to-poster+0.0.9.patch: -------------------------------------------------------------------------------- 1 | diff --git a/node_modules/markdown-to-poster/dist/markdown-to-poster.js b/node_modules/markdown-to-poster/dist/markdown-to-poster.js 2 | index 136280b..c9634bc 100644 3 | --- a/node_modules/markdown-to-poster/dist/markdown-to-poster.js 4 | +++ b/node_modules/markdown-to-poster/dist/markdown-to-poster.js 5 | @@ -17938,7 +17938,7 @@ const ig = ({ 6 | { 7 | components: { 8 | img(a) { 9 | - const { node: u, src: o, ...l } = a, f = o && `https://api.allorigins.win/raw?url=${encodeURIComponent(o)}`; 10 | + const { node: u, src: o, ...l } = a, f = o && `${o}`; 11 | return /* @__PURE__ */ Se("img", { ...l, src: f }); 12 | } 13 | }, 14 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["dom", "dom.iterable", "esnext"], 4 | "allowJs": true, 5 | "skipLibCheck": true, 6 | "strict": true, 7 | "noEmit": true, 8 | "esModuleInterop": true, 9 | "module": "esnext", 10 | "moduleResolution": "bundler", 11 | "resolveJsonModule": true, 12 | "isolatedModules": true, 13 | "jsx": "preserve", 14 | "incremental": true, 15 | "noImplicitAny": false, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./src/*"] 23 | } 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 26 | "exclude": ["node_modules"] 27 | } 28 | -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | import createMDX from '@next/mdx' 3 | 4 | const nextConfig = { 5 | // Configure `pageExtensions` to include markdown and MDX files 6 | pageExtensions: ['js', 'jsx', 'md', 'mdx', 'ts', 'tsx'], 7 | images: { 8 | remotePatterns: [ 9 | { 10 | protocol: 'https', 11 | hostname: '**', 12 | }, 13 | { 14 | protocol: 'http', 15 | hostname: '**', 16 | }, 17 | ], 18 | }, 19 | }; 20 | 21 | const withMDX = createMDX({ 22 | // Add markdown plugins here, as desired 23 | extension: /\.mdx?$/, 24 | options: { 25 | remarkPlugins: [], 26 | rehypePlugins: [], 27 | }, 28 | }) 29 | 30 | export default withMDX(nextConfig) 31 | -------------------------------------------------------------------------------- /src/components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as LabelPrimitive from "@radix-ui/react-label" 5 | import { cva, type VariantProps } from "class-variance-authority" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const labelVariants = cva( 10 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" 11 | ) 12 | 13 | const Label = React.forwardRef< 14 | React.ElementRef, 15 | React.ComponentPropsWithoutRef & 16 | VariantProps 17 | >(({ className, ...props }, ref) => ( 18 | 23 | )) 24 | Label.displayName = LabelPrimitive.Root.displayName 25 | 26 | export { Label } 27 | -------------------------------------------------------------------------------- /fly.toml: -------------------------------------------------------------------------------- 1 | # fly.toml app configuration file generated for markdown-to-image-serve on 2024-01-01T00:00:00Z 2 | # 3 | # See https://fly.io/docs/reference/configuration/ for information about how to use this file. 4 | 5 | app = "markdown-to-image-serve" 6 | primary_region = "hkg" 7 | 8 | [build] 9 | dockerfile = "Dockerfile" 10 | 11 | [env] 12 | NODE_ENV = "production" 13 | NEXT_PUBLIC_BASE_URL = "https://markdown-to-image-serve.fly.dev" 14 | 15 | [http_service] 16 | internal_port = 3000 17 | force_https = true 18 | auto_stop_machines = true 19 | auto_start_machines = true 20 | min_machines_running = 0 21 | processes = ["app"] 22 | 23 | [[http_service.checks]] 24 | grace_period = "10s" 25 | interval = "30s" 26 | method = "GET" 27 | timeout = "5s" 28 | path = "/api/hello" 29 | 30 | [[vm]] 31 | cpu_kind = "shared" 32 | cpus = 1 33 | memory_mb = 1024 -------------------------------------------------------------------------------- /src/components/ui/textarea.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | export interface TextareaProps 6 | extends React.TextareaHTMLAttributes {} 7 | 8 | const Textarea = React.forwardRef( 9 | ({ className, ...props }, ref) => { 10 | return ( 11 |