بازگشت به وبلاگ

راه‌اندازی Tiptap در Next.js: پنل ادمین قابل‌ویرایش، رندر عمومی فقط‌خواندنی

۳ مرداد ۱۴۰۵4 دقیقه مطالعه
راه‌اندازی Tiptap در Next.js: پنل ادمین قابل‌ویرایش، رندر عمومی فقط‌خواندنی

هر پست و توضیح پروژه توی این سایت — همینی هم که الان می‌خونی — به‌شکل Tiptap JSON ذخیره شده، نه HTML یا Markdown. همین یه تصمیم کل راه‌اندازی رو شکل می‌ده: یه کانفیگ ادیتور برای پنل ادمین، یه لیست extension مشترک که هم ادیتور هم صفحه‌ی عمومی ازش import می‌کنن، و یه قانون سفت‌وسخت که این دوتا هیچ‌وقت از هم جدا نیفتن.

چرا JSON به‌جای HTML یا Markdown

  • ساختاریافته و قابل‌کوئریه — Prisma به‌شکل یه ستون ساده‌ی Json ذخیره‌ش می‌کنه، بدون نیاز به یه مرحله‌ی جدای sanitize کردن HTML قبل از اینکه ذخیره‌ش امن باشه.

  • بدون افت اطلاعات رفت‌وبرگشت می‌کنه — ادیتور می‌تونه یه پست رو دقیقاً همون‌طوری که ذخیره شده برگردونه، چون داره همون درخت node ای که نوشته رو می‌خونه.

  • فقط یه‌بار، سمت سرور رندر می‌شه — صفحه‌ی عمومی بلاگ generateHTML() تیپ‌تپ رو با همون لیست extension صدا می‌زنه، پس هیچ جاوااسکریپت ادیتوری فقط برای نمایش یه مقاله فرستاده نمی‌شه.

۱. یه لیست extension مشترک

مهم‌ترین فایل توی این راه‌اندازی، کامپوننت ادیتور نیست — لیست extension‌ایه که هر دو طرف ازش import می‌کنن. اگه ادیتور یه node رو پشتیبانی کنه که renderer نشناستش، اون محتوا بی‌سروصدا از صفحه‌ی عمومی محو می‌شه.

// src/lib/tiptap-extensions.ts
import StarterKit from "@tiptap/starter-kit";
import Link from "@tiptap/extension-link";
import Image from "@tiptap/extension-image";
import { Table } from "@tiptap/extension-table";
import TableRow from "@tiptap/extension-table-row";
import TableHeader from "@tiptap/extension-table-header";
import TableCell from "@tiptap/extension-table-cell";
import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight";
import { createLowlight, common } from "lowlight";

const lowlight = createLowlight(common);
lowlight.registerAlias({ typescript: ["ts", "tsx"], javascript: ["js", "jsx"], bash: ["sh", "shell"] });

// مهم: هرجا Tiptap استفاده می‌شه این رو عیناً یکی نگه دار — ادیتور
// (کلاینت) و generateHTML (سرور) باید دقیقاً همین آرایه رو import کنن.
export const tiptapExtensions = [
  StarterKit.configure({
    heading: { levels: [2, 3, 4] },
    codeBlock: false, // این‌جا غیرفعاله تا CodeBlockLowlight (با syntax highlighting) جاش رو بگیره
  }),
  CodeBlockLowlight.configure({ lowlight }),
  Link.configure({
    openOnClick: false,
    autolink: true,
    HTMLAttributes: { class: "text-primary underline underline-offset-4" },
  }),
  Image.configure({ inline: false, allowBase64: false }),
  Table.configure({ resizable: false }),
  TableRow,
  TableHeader,
  TableCell,
];

۲. سمت قابل‌ویرایش (پنل ادمین، Client Component)

"use client";
import { useEditor, EditorContent } from "@tiptap/react";
import { tiptapExtensions } from "@/lib/tiptap-extensions";
import type { JSONContent } from "@tiptap/core";

export function TiptapEditor({
  content,
  onChange,
}: {
  content: JSONContent;
  onChange: (json: JSONContent) => void;
}) {
  const editor = useEditor({
    extensions: tiptapExtensions,
    content,
    immediatelyRender: false, // از یه hydration mismatch جلوگیری می‌کنه — Tiptap توی Next.js بلند دربارش هشدار می‌ده
    onUpdate: ({ editor }) => onChange(editor.getJSON()),
  });

  return <EditorContent editor={editor} className="prose dark:prose-invert max-w-none" />;
}

هندلر submit این فرم (یه Server Action، هم‌الگو با هر فرم دیگه‌ی این سایت) خروجی editor.getJSON() رو مستقیم به‌شکل رشته توی ستون‌های Prisma به‌نام Post.contentEn / contentFa ذخیره می‌کنه — این مرحله هیچ تبدیل HTML‌ای در کار نیست.

۳. سمت فقط‌خواندنی (صفحه‌ی عمومی بلاگ، Server Component)

صفحه‌ی عمومی اصلاً هیچ‌وقت یه ادیتور Tiptap رو mount نمی‌کنه. مستقیم JSON ذخیره‌شده رو از دیتابیس می‌خونه و سمت سرور با generateHTML() به HTML تبدیلش می‌کنه — صفر جاوااسکریپت ادیتور به خواننده فرستاده می‌شه.

// src/app/[locale]/blog/[slug]/page.tsx
import { generateHTML } from "@tiptap/html";
import { tiptapExtensions } from "@/lib/tiptap-extensions";
import { postRepository } from "@/lib/repositories/post-repository";

export default async function BlogPostPage({ params }: { params: { slug: string; locale: "en" | "fa" } }) {
  const post = await postRepository.findBySlug(params.slug);
  const json = params.locale === "fa" ? post.contentFa : post.contentEn;

  // generateHTML به‌صورت سینک روی سرور اجرا می‌شه، با دقیقاً همون لیست
  // extension که ادیتور داره — این چیزیه که رندرینگ رو consistent نگه می‌داره.
  const html = generateHTML(json as object, tiptapExtensions);

  return (
    <article
      className="prose dark:prose-invert max-w-none"
      dangerouslySetInnerHTML={{ __html: html }}
    />
  );
}

خروجی generateHTML() همچنان از dangerouslySetInnerHTML رد می‌شه، پس دقیقاً به همون اندازه‌ی هر HTML دیگه‌ای که این‌جوری تزریق می‌کنی، trusted‌ه. چون فقط یه ادمین احرازهویت‌شده می‌تونه این محتوا رو بنویسه (هیچ فرم ارسال عمومی‌ای وجود نداره)، این یه مصالحه‌ی قابل‌قبوله — اگه این JSON از یه کاربر untrusted می‌اومد، قابل‌قبول نبود.

۴. بلاک‌های کد با syntax highlighting

CodeBlockLowlight نود codeBlock رو با lowlight (یه syntax highlighter مبتنی بر گرامرهای highlight.js) جفت می‌کنه، پس کد داخل یه پست هم توی ادیتور هم توی صفحه‌ی رندرشده syntax highlighting واقعی می‌گیره، با استفاده از همون instance و alias‌های زبان توی هر دو جا.

۵. عکس‌ها — آپلود می‌شن، نه پیست‌شده به‌شکل base64

Image.configure({ allowBase64: false }) عمدیه: پیست کردن یه اسکرین‌شات توی ادیتور باید آپلودش کنه (توی این پروژه، به Vercel Blob) و یه URL واقعی درج کنه، نه اینکه ستون JSON رو با یه رشته‌ی base64 چند مگابایتی متورم کنه. دکمه‌ی عکس ادیتور خودش آپلود رو مدیریت می‌کنه و به‌محض گرفتن URL، editor.chain().setImage({ src: url }) رو صدا می‌زنه.

چک‌لیست

  1. یه لیست extension، که هم ادیتور هم generateHTML ازش import می‌کنن — هیچ‌وقت جداجدا تعریفشون نکن.

  2. immediatelyRender: false روی useEditor برای جلوگیری از هشدارهای SSR/hydration توی Next.js.

  3. JSON خام رو توی دیتابیس ذخیره کن، فقط موقع رندر و روی سرور به HTML تبدیلش کن.

  4. allowBase64: false روی extension عکس — آپلود به استوریج و درج URL به‌جاش.

  5. خروجی generateHTML رو فقط به این دلیل trusted فرض کن که محتوا رو ادمین نوشته — هیچ‌وقت این کارو برای محتوای ارسالی کاربر بدون sanitize کردن نکن.