פיתוח תוספים ל-Payload CMS
דמיינו שיש לכם 10 אוספים, וכל אחד מהם צריך את אותם שדות SEO—כותרת מטא, תיאור, תמונה ודגל noIndex. הוספה ידנית אורכת 2-3 שעות לכל אוסף, ותחזוקה יכולה לעלות 1,000 דולר בשנה עבור פרויקט עם 10 אוספים. תוספים של Payload CMS פותרים בעיה זו אחת ולתמיד: אתם מתארים את השדות פעם אחת בקוד התוסף, ואז מצמידים אותו לאוספים הנדרשים בשורה אחת. אנו מפתחים תוספים כאלה במפתח מלא עם אחריות איכות ותמיכה. הניסיון שלנו: למעלה מ-5 שנים ו-15+ תוספים לפרויקטים שונים. אם אתם צריכים תוסף, צרו קשר לייעוץ.
למה תוספים מועילים
הבעיה המרכזית היא כפילות קוד. אם פונקציונליות נדרשת במספר אוספים, העתקת שדות ו-hooks מנפחת את בסיס הקוד. הבעיה השנייה היא מורכבות התחזוקה: שינוי לוגיקה דורש עריכות בכל מקום. השלישית היא היעדר ממשק אחיד לפעולות דומות. תוספים מרכזים פונקציונליות: SEO, ביקורת, חיפוש, נקודות קצה מותאמות אישית. לדוגמה, תוסף SEO מוסיף שדות מטא זהים לכל האוספים שצוינו, בעוד תוסף ביקורת מתעד את כל השינויים באוסף יומן יחיד. תוספים מהירים פי 3 ליישום מאשר קידוד ידני ומפחיתים את זמן הפיתוח ב-40%. עלויות היישום מוחזרות תוך חודשים בשל תחזוקה מופחתת. השוואה להוספה ידנית: תוסף מהיר פי 3-4 לתחזוקה ופחות מועד לשגיאות.
כיצד לפתח תוסף עבור Payload CMS
לפי התיעוד הרשמי של Payload CMS, תוסף הוא "פונקציה שמקבלת קונפיגורציה ומחזירה קונפיגורציה שונה." אין כאן קסם: התוסף פשוט מוסיף אוספים, שדות, hooks, נקודות קצה ורכיבים לקונפיגורציה הקיימת לפני אתחול ה-CMS. התוספים הרשמיים (@payloadcms/seo, @payloadcms/form-builder) פועלים לפי אותו מודל.
// Тип плагина
type Plugin = (incomingConfig: Config) => Config
// Простейший плагин
const myPlugin: Plugin = (config) => {
return {
...config,
collections: [
...(config.collections || []), // добавить коллекцию
],
hooks: {
...config.hooks,
afterInit: [
...(config.hooks?.afterInit || []), // добавить хук
],
},
}
}
export default buildConfig({
plugins: [myPlugin],
}) דוגמאות לתוספים: SEO, ביקורת, חיפוש
ארכיטקטורת התוספים של Payload פשוטה אך חזקה. תוסף SEO מוסיף שדות מטא לכל האוספים שצוינו:
// plugins/seo/index.ts
import type { Config, CollectionConfig, GlobalConfig } from 'payload/types'
interface SEOPluginConfig {
collections?: string[] // slug коллекций, куда добавить SEO-поля
globals?: string[]
uploadsCollection?: string
generateTitle?: (doc: any) => string
generateDescription?: (doc: any) => string
}
export const seoPlugin = (pluginConfig: SEOPluginConfig) => (config: Config): Config => {
const seoFields = [
{
name: 'meta',
type: 'group' as const,
label: 'SEO',
admin: {
position: 'sidebar' as const,
},
fields: [
{
name: 'title',
type: 'text' as const,
admin: {
description: ({ doc }: any) => pluginConfig.generateTitle?.(doc) || 'Автозаполнение: заголовок документа',
},
},
{
name: 'description',
type: 'textarea' as const,
maxLength: 160,
},
{
name: 'image',
type: 'upload' as const,
relationTo: pluginConfig.uploadsCollection || 'media',
},
{
name: 'noIndex',
type: 'checkbox' as const,
defaultValue: false,
},
],
},
]
return {
...config,
collections: config.collections?.map(collection => {
if (pluginConfig.collections?.includes(collection.slug)) {
return {
...collection,
fields: [...(collection.fields || []), ...seoFields],
}
}
return collection
}),
globals: config.globals?.map(global => {
if (pluginConfig.globals?.includes(global.slug)) {
return {
...global,
fields: [...(global.fields || []), ...seoFields],
}
}
return global
}),
hooks: {
...config.hooks,
afterRead: [
...(config.hooks?.afterRead || []),
({ doc }: any) => {
if (!doc.meta?.title && pluginConfig.generateTitle) {
doc.meta = {
...doc.meta,
title: pluginConfig.generateTitle(doc),
}
}
return doc
},
],
},
}
}תוסף ביקורת מתעד את כל השינויים:
// plugins/audit-log/index.ts
import type { Config } from 'payload/types'
interface AuditLogConfig {
collections: string[]
}
export const auditLogPlugin = ({ collections }: AuditLogConfig) => (config: Config): Config => {
const auditCollection = {
slug: 'audit-logs',
admin: {
hidden: true,
},
access: {
read: ({ req }: any) => req.user?.role === 'admin',
create: () => false,
update: () => false,
delete: () => false,
},
fields: [
{
name: 'collection',
type: 'text' as const,
},
{
name: 'docId',
type: 'text' as const,
},
{
name: 'operation',
type: 'text' as const,
},
{
name: 'user',
type: 'relationship' as const,
relationTo: 'users' as const,
},
{
name: 'before',
type: 'json' as const,
},
{
name: 'after',
type: 'json' as const,
},
{
name: 'timestamp',
type: 'date' as const,
},
],
}
const auditedCollections = config.collections?.map(collection => {
if (!collections.includes(collection.slug)) return collection
return {
...collection,
hooks: {
...collection.hooks,
afterChange: [
...(collection.hooks?.afterChange || []),
async ({ doc, previousDoc, operation, req }: any) => {
if (!req.payload) return
await req.payload.create({
collection: 'audit-logs',
data: {
collection: collection.slug,
docId: String(doc.id),
operation,
user: req.user?.id,
before: previousDoc || null,
after: doc,
timestamp: new Date().toISOString(),
},
disableVerificationEmail: true,
})
},
],
},
}
})
return {
...config,
collections: [
...(auditedCollections || []),
auditCollection,
],
}
}תוסף חיפוש מוסיף נקודת קצה מותאמת אישית /search:
// plugins/search/index.ts
export const searchPlugin = (config: Config): Config => ({
...config,
endpoints: [
...(config.endpoints || []),
{
path: '/search',
method: 'get' as const,
handler: async (req: any, res: any) => {
const { q } = req.query
if (!q) return res.json({ docs: [] })
const results = await Promise.all([
req.payload.find({
collection: 'posts',
where: {
or: [
{ title: { like: q } },
{ excerpt: { like: q } }
]
},
limit: 5,
}),
req.payload.find({
collection: 'products',
where: {
name: { like: q }
},
limit: 5,
}),
])
return res.json({
docs: [
...results[0].docs.map(d => ({ ...d, _type: 'post' })),
...results[1].docs.map(d => ({ ...d, _type: 'product' })),
],
})
},
},
],
}) פרסום תוסף כחבילת npm
// package.json плагина
{
"name": "@myorg/payload-plugin-seo",
"version": "1.0.0",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"peerDependencies": {
"payload": "^2.0.0"
},
"scripts": {
"build": "tsc"
}
}ייצאו את התוסף מ-src/index.ts: // Тип плагина type Plugin = (incomingConfig: Config) => Config // Простейший плагин const myPlugin: Plugin = (config) => { return { ...config, collections: [ ...(config.collections || []), // добавить коллекцию ], hooks: { ...config.hooks, afterInit: [ ...(config.hooks?.afterInit || []), // добавить хук ], }, } } export default buildConfig({ plugins: [myPlugin], }) . לאחר בנייה, פרסמו ל-npm. תיעוד ב-README הוא חובה.
השוואת סוגי תוספים
| סוג תוסף | מטרה | מורכבות | דוגמת שימוש |
|---|---|---|---|
| SEO | הוספת שדות מטא | נמוכה | // plugins/seo/index.ts import type { Config, CollectionConfig, GlobalConfig } from 'payload/types' interface SEOPluginConfig { collections?: string[] // slug коллекций, куда добавить SEO-поля globals?: string[] uploadsCollection?: string generateTitle?: (doc: any) => string generateDescription?: (doc: any) => string } export const seoPlugin = (pluginConfig: SEOPluginConfig) => (config: Config): Config => { const seoFields = [ { name: 'meta', type: 'group' as const, label: 'SEO', admin: { position: 'sidebar' as const }, fields: [ { name: 'title', type: 'text' as const, admin: { description: ({ doc }: any) => pluginConfig.generateTitle?.(doc) || 'Автозаполнение: заголовок документа', }, }, { name: 'description', type: 'textarea' as const, maxLength: 160, }, { name: 'image', type: 'upload' as const, relationTo: pluginConfig.uploadsCollection || 'media', }, { name: 'noIndex', type: 'checkbox' as const, defaultValue: false, }, ], }, ] return { ...config, collections: config.collections?.map(collection => { if (pluginConfig.collections?.includes(collection.slug)) { return { ...collection, fields: [...(collection.fields || []), ...seoFields], } } return collection }), globals: config.globals?.map(global => { if (pluginConfig.globals?.includes(global.slug)) { return { ...global, fields: [...(global.fields || []), ...seoFields], } } return global }), hooks: { ...config.hooks, afterRead: [ ...(config.hooks?.afterRead || []), ({ doc }: any) => { if (!doc.meta?.title && pluginConfig.generateTitle) { doc.meta = { ...doc.meta, title: pluginConfig.generateTitle(doc), } } return doc }, ], }, } } |
| ביקורת | תיעוד שינויים | בינונית | // plugins/audit-log/index.ts import type { Config } from 'payload/types' interface AuditLogConfig { collections: string[] } export const auditLogPlugin = ({ collections }: AuditLogConfig) => (config: Config): Config => { const auditCollection = { slug: 'audit-logs', admin: { hidden: true }, access: { read: ({ req }: any) => req.user?.role === 'admin', create: () => false, update: () => false, delete: () => false, }, fields: [ { name: 'collection', type: 'text' as const }, { name: 'docId', type: 'text' as const }, { name: 'operation', type: 'text' as const }, { name: 'user', type: 'relationship' as const, relationTo: 'users' as const }, { name: 'before', type: 'json' as const }, { name: 'after', type: 'json' as const }, { name: 'timestamp', type: 'date' as const }, ], } const auditedCollections = config.collections?.map(collection => { if (!collections.includes(collection.slug)) return collection return { ...collection, hooks: { ...collection.hooks, afterChange: [ ...(collection.hooks?.afterChange || []), async ({ doc, previousDoc, operation, req }: any) => { if (!req.payload) return await req.payload.create({ collection: 'audit-logs', data: { collection: collection.slug, docId: String(doc.id), operation, user: req.user?.id, before: previousDoc || null, after: doc, timestamp: new Date().toISOString(), }, disableVerificationEmail: true, }) }, ], }, } }) return { ...config, collections: [ ...(auditedCollections || []), auditCollection, ], } } |
| חיפוש | נקודת קצה מותאמת אישית | גבוהה | // plugins/search/index.ts export const searchPlugin = (config: Config): Config => ({ ...config, endpoints: [ ...(config.endpoints || []), { path: '/search', method: 'get' as const, handler: async (req: any, res: any) => { const { q } = req.query if (!q) return res.json({ docs: [] }) const results = await Promise.all([ req.payload.find({ collection: 'posts', where: { or: [{ title: { like: q } }, { excerpt: { like: q } }] }, limit: 5, }), req.payload.find({ collection: 'products', where: { name: { like: q } }, limit: 5, }), ]) return res.json({ docs: [ ...results[0].docs.map(d => ({ ...d, _type: 'post' })), ...results[1].docs.map(d => ({ ...d, _type: 'product' })), ], }) }, }, ], }) |
סוגי hooks המשמשים בתוספים
| Hook | מטרה | דוגמה בתוסף |
|---|---|---|
| beforeChange | אימות לפני שמירה | בדיקת ייחודיות slug |
| afterChange | תיעוד שינויים | תוסף ביקורת |
| beforeRead | שינוי נתונים לפני קריאה | מילוי אוטומטי של שדות מטא |
| afterRead | עיבוד לאחר קריאה | תוסף SEO (הוספת כותרת מטא) |
תהליך פיתוח תוסף
- ניתוח דרישות: קביעת אוספים, hooks ונקודות קצה. התחשבות בהתנגשויות פוטנציאליות עם שדות קיימים.
- עיצוב ממשק: יצירת טיפוסי TypeScript לקונפיגורציית התוסף. שימוש בטיפוסים קפדניים למניעת שגיאות בזמן קומפילציה.
- יישום: כתיבת קוד התוסף, שימוש ב-hooks גלובליים לשינויים מרכזיים. כיסוי הקוד בבדיקות יחידה (Jest) בכיסוי של ≥90%.
- בדיקות: בדיקת תרחישים קריטיים, כולל מקרי קצה (אוספים ריקים, hooks חסרים). ביצוע גם בדיקות אינטגרציה עם מופע Payload CMS אמיתי.
- פרסום ותיעוד: הכנת README עם דוגמאות שימוש, קומפילציה של TypeScript ל-dist, ופרסום ל-npm. התהליך כולו אורך בין 3 ל-10 ימים בהתאם למורכבות.
מה כלול בעבודה
- קוד מקור של התוסף ב-TypeScript עם טיפוסים מלאים.
- בדיקות יחידה (Jest) עם כיסוי של ≥90%.
- תיעוד (README) עם דוגמאות קונפיגורציה ושימוש.
- תמיכה לאחר פריסה: תיקונים והתאמות למשך חודש.
טעויות נפוצות בפיתוח תוספים
- אי-בדיקה שאוספים ו-hooks עשויים להיות undefined או ריקים — גורם לשגיאות זמן ריצה.
- שימוש ב-hook afterInit להוספת שדות במקום שינוי ישיר של אוספים — לא ניתן להחיל שדות לאחר אתחול.
- שכחת ייצוא טיפוסי קונפיגורציית התוסף — משתמשים מאבדים השלמה אוטומטית ב-IDE שלהם.
מתי להזמין תוסף?
כל תוסף שלנו נבדק ומתועד. אנו מוצאים פתרונות ארכיטקטוניים אופטימליים, תוך התחשבות במאפייני הפרויקט שלכם. תוסף מחזיר את ההשקעה תוך מספר חודשים של שימוש — חיסכון בזמן על הוספת שדות חוזרים יכול להגיע ל-40%. אם אתם רוצים פתרון מוכן עם אחריות איכות, הזמינו פיתוח תוסף — צרו קשר כדי לדון במשימה שלכם.







