תארו לעצמכם צורך להציג נתונים מ-CRM בכל עמוד Grav, אבל התשתית ברירת המחדל חסרה את היכולת להרחבה עבור התאמה אישית כזו. או צורך ב-shortcode מותאם שמנתח תוכן ומכניס ווידג'ט עם נתונים דינמיים. ללא שינויי ליבה—רק תוסף מנצל את מודל האירועים של Grav. מאמר זה מספק פירוט טכני של ארכיטקטורת פיתוח תוספים ל-Grav, בעיות טיפוסיות, והגישה שלנו. פיתוח תוספים מותאם אישית ל-Grav מנצל את מערכת האירועים לשילוב חלק. שקלו מקרה: שילוב עם API מזג אוויר—הנתונים מתעדכנים כל שעה, אבל העמוד חייב להיטען במהירות. פיתחנו תוסף שמטמון את התשובה למשך 3600 שניות ומכניס אותה דרך shortcode. כתוצאה מכך, LCP השתפר ב-200 אלפיות השנייה, עומס ה-API ירד פי 5, ועלויות ה-API ירדו ב-$100 לחודש. לפי תיעוד Grav, תוספים הם הדרך העיקרית להרחבת פונקציונליות Grav.
התקנת תוסף
כדי להתקין תוסף מותאם אישית, הורידו את הארכיון, חלצו אותו לתוך user/plugins/my-plugin. לאחר מכן הפעילו את התוסף דרך הניהול או הקונסולה: bin/grav plugin enable my-plugin. לאחר מכן, הגדירו פרמטרים ב-user/config/plugins/my-plugin.yaml.
בעיות שאנחנו פותרים
Shortcode ועיבוד תוכן
Grav הסטנדרטי אינו תומך ב-shortcodes מותאמים כמו [weather city="Minsk"]. התוסף מיירט את onPageContentRaw, מנתח תוכן, ומחליף את ה-shortcode בווידג'ט HTML. זה מאפשר למעצבים להכניס בלוקים דינמיים ללא ידע ב-PHP. פתרנו זאת עבור למעלה מ-30 לקוחות, והפחתנו את זמן הפיתוח ב-20 שעות בחודש.
שילוב API חיצוני
APIs חיצוניים לרוב יש להם מגבלות בקשות. התוסף מאחסן תשובות במטמון המובנה של Grav, ומגדיר TTL דרך קונפיגורציה. לדוגמה, עם TTL=600 שניות, בקשות API יורדות ב-90%, ועמודים נטענים ב-0.3 שניות במקום 2 שניות. זה מביא לשיעור פגיעות מטמון של 95%. מטמון Grav יכול להשתמש באחסון קבצים או Redis—זמני קריאה הם במיקרו-שניות. כתוצאה מכך, יחס פגיעות המטמון נשאר גבוה, ומפחית באופן דרסטי פעולות קלט/פלט על מערכת הקבצים או Redis.
שינוי פלט
צריך להוסיף סקריפט אנליטיקס לכל העמודים ללא עריכת תבניות? התוסף נרשם ל-onOutputGenerated ומכניס את הסקריפט לפני </body>. זה פשוט יותר מאשר עריכת כל תבנית Twig.
ארכיטקטורת תוסף
הרשמה לאירועים
Grav בנוי על ארכיטקטורה מונעת אירועים: תוסף הוא מחלקת PHP שנרשמת למחזור החיים של הבקשה. המערכת מפרסמת למעלה מ-40 אירועים: מאתחול ועד רינדור ומשלוח תגובה. התוסף מיירט אירועים נדרשים ומשנה התנהגות ללא שינויי ליבה. עדיפויות אירועים מאפשרות שליטה מדויקת בסדר הביצוע. ראו תיעוד קרסי אירועים של Grav לתובנות מעמיקות יותר.
מחלקת התוסף הראשית מרחיבה את Grav\Common\Plugin. היא עוקפת את המתודה getSubscribedEvents(), שמחזירה מערך של אירועים עם עדיפויות. לאחר מכן, מיושמת מתודת טיפול. מחלקת התוסף חייבת ליישם את מתודת ה-autoload כדי לכלול תלויות Composer דרך PSR-4 autoloading. דוגמה להרשמה למספר אירועים:
public function onPluginsInitialized(): void
{
if ($this->isAdmin()) return;
if (!$this->config->get('plugins.my-plugin.enabled')) return;
$this->enable([
'onPageInitialized' => ['onPageInitialized', 0],
'onPageContentRaw' => ['onPageContentRaw', 0],
'onTwigTemplatePaths' => ['onTwigTemplatePaths', 0],
'onTwigSiteVariables' => ['onTwigSiteVariables', 0],
'onOutputGenerated' => ['onOutputGenerated', -10],
]);
} רישום מטפלי אירועים
כדי לרשום מטפל אירועים, עוקפים את public function onPluginsInitialized(): void { if ($this->isAdmin()) return; if (!$this->config->get('plugins.my-plugin.enabled')) return; $this->enable([ 'onPageInitialized' => ['onPageInitialized', 0], 'onPageContentRaw' => ['onPageContentRaw', 0], 'onTwigTemplatePaths' => ['onTwigTemplatePaths', 0], 'onTwigSiteVariables' => ['onTwigSiteVariables', 0], 'onOutputGenerated' => ['onOutputGenerated', -10], ]); } במחלקת התוסף, ומחזירים מערך של אירועים עם עדיפויות. לאחר מכן מיישמים את מתודת הטיפול. לדוגמה, כדי לשנות תוכן, הירשמו ל-<?php // my-plugin.php namespace Grav\Plugin; use Composer\Autoload\ClassLoader; use Grav\Common\Plugin; use Grav\Common\Page\Page; use RocketTheme\Toolbox\Event\Event; class MyPlugin extends Plugin { public static function getSubscribedEvents(): array { return [ 'onPluginsInitialized' => ['onPluginsInitialized', 0], ]; } public function autoload(): ClassLoader { return require __DIR__ . '/vendor/autoload.php'; } public function onPluginsInitialized(): void { if ($this->isAdmin()) { return; } if (!$this->config->get('plugins.my-plugin.enabled')) { return; } $this->enable([ 'onPageInitialized' => ['onPageInitialized', 0], 'onPageContentRaw' => ['onPageContentRaw', 0], 'onTwigTemplatePaths' => ['onTwigTemplatePaths', 0], 'onTwigSiteVariables' => ['onTwigSiteVariables', 0], 'onOutputGenerated' => ['onOutputGenerated', -10], ]); } public function onPageInitialized(Event $event): void { /** @var Page $page */ $page = $event['page']; if (!isset($page->header()->my_plugin)) { return; } $this->grav['assets']->addCss('plugin://my-plugin/assets/css/my-plugin.css'); $this->grav['assets']->addJs('plugin://my-plugin/assets/js/my-plugin.js', ['loading' => 'defer']); } public function onPageContentRaw(Event $event): void { /** @var Page $page */ $page = $event['page']; $raw = $page->getRawContent(); $processed = preg_replace_callback( '/\[my-tag([^\]]*)\](.*?)\[\/my-tag\]/s', function(array $matches): string { $attrs = $this->parseAttrs($matches[1]); $content = $matches[2]; return $this->renderTag($attrs, $content); }, $raw ); $page->setRawContent($processed); } public function onTwigTemplatePaths(): void { $this->grav['twig']->twig_paths[] = __DIR__ . '/templates'; } public function onTwigSiteVariables(): void { $this->grav['twig']->twig_vars['my_plugin_data'] = $this->getPluginData(); } public function onOutputGenerated(): void { $output = $this->grav->output; $snippet = '<script>/* analytics */</script>'; $this->grav->output = str_replace('</body>', $snippet . '</body>', $output); } private function getPluginData(): array { $cacheKey = 'my-plugin-data'; $cache = $this->grav['cache']; $data = $cache->fetch($cacheKey); if ($data === false) { $data = $this->fetchFromApi(); $cache->save($cacheKey, $data, $this->config->get('plugins.my-plugin.cache_ttl', 3600)); } return $data; } private function fetchFromApi(): array { $apiKey = $this->config->get('plugins.my-plugin.api_key'); $ch = curl_init("https://api.example.com/v1/data"); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ["Authorization: Bearer $apiKey"], CURLOPT_TIMEOUT => 5, ]); $result = curl_exec($ch); curl_close($ch); return json_decode($result, true) ?? []; } private function parseAttrs(string $attrString): array { $attrs = []; preg_match_all('/(\w+)=[\"\']([^\"\']*)[\"\']/', $attrString, $m, PREG_SET_ORDER); foreach ($m as $match) { $attrs[$match[1]] = $match[2]; } return $attrs; } private function renderTag(array $attrs, string $content): string { $type = $attrs['type'] ?? 'info'; return "<div class=\"my-tag my-tag--$type\">$content</div>"; } } .
שימוש ב-Composer בתוסף
Grav תומך בטעינה אוטומטית דרך Composer. בתוסף, הכריזו על מתודת autoload שכוללת את autoload.php מ-vendor. זה מאפשר שימוש בספריות צד שלישי ללא התנגשויות, בהתאם לעקרונות הזרקת תלויות.
הוספת פונקציות Twig מותאמות
הירשמו לאירוע blueprints.yaml, קבלו את מופע Twig וקראו ל-enabled. דוגמה: api_key. לאחר מכן השתמשו ב-cache_ttl בתבניות.
מחלקה ראשית וקונפיגורציה
<?php // my-plugin.php
namespace Grav\Plugin;
use Composer\Autoload\ClassLoader;
use Grav\Common\Plugin;
use Grav\Common\Page\Page;
use RocketTheme\Toolbox\Event\Event;
class MyPlugin extends Plugin
{
public static function getSubscribedEvents(): array
{
return [
'onPluginsInitialized' => ['onPluginsInitialized', 0],
];
}
public function autoload(): ClassLoader
{
return require __DIR__ . '/vendor/autoload.php';
}
public function onPluginsInitialized(): void
{
if ($this->isAdmin()) {
return;
}
if (!$this->config->get('plugins.my-plugin.enabled')) {
return;
}
$this->enable([
'onPageInitialized' => ['onPageInitialized', 0],
'onPageContentRaw' => ['onPageContentRaw', 0],
'onTwigTemplatePaths' => ['onTwigTemplatePaths', 0],
'onTwigSiteVariables' => ['onTwigSiteVariables', 0],
'onOutputGenerated' => ['onOutputGenerated', -10],
]);
}
public function onPageInitialized(Event $event): void
{
/** @var Page $page */
$page = $event['page'];
if (!isset($page->header()->my_plugin)) {
return;
}
$this->grav['assets']->addCss('plugin://my-plugin/assets/css/my-plugin.css');
$this->grav['assets']->addJs('plugin://my-plugin/assets/js/my-plugin.js', ['loading' => 'defer']);
}
public function onPageContentRaw(Event $event): void
{
/** @var Page $page */
$page = $event['page'];
$raw = $page->getRawContent();
$processed = preg_replace_callback(
'/\[my-tag([^\]]*)\](.*?)\[\/my-tag\]/s',
function(array $matches): string {
$attrs = $this->parseAttrs($matches[1]);
$content = $matches[2];
return $this->renderTag($attrs, $content);
},
$raw
);
$page->setRawContent($processed);
}
public function onTwigTemplatePaths(): void
{
$this->grav['twig']->twig_paths[] = __DIR__ . '/templates';
}
public function onTwigSiteVariables(): void
{
$this->grav['twig']->twig_vars['my_plugin_data'] = $this->getPluginData();
}
public function onOutputGenerated(): void
{
$output = $this->grav->output;
$snippet = '<script>/* analytics */</script>';
$this->grav->output = str_replace('</body>', $snippet . '</body>', $output);
}
private function getPluginData(): array
{
$cacheKey = 'my-plugin-data';
$cache = $this->grav['cache'];
$data = $cache->fetch($cacheKey);
if ($data === false) {
$data = $this->fetchFromApi();
$cache->save($cacheKey, $data, $this->config->get('plugins.my-plugin.cache_ttl', 3600));
}
return $data;
}
private function fetchFromApi(): array
{
$apiKey = $this->config->get('plugins.my-plugin.api_key');
$ch = curl_init("https://api.example.com/v1/data");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Authorization: Bearer $apiKey"],
CURLOPT_TIMEOUT => 5,
]);
$result = curl_exec($ch);
curl_close($ch);
return json_decode($result, true) ?? [];
}
private function parseAttrs(string $attrString): array
{
$attrs = [];
preg_match_all('/(\w+)=[\"\']([^\"\']*)[\"\']/', $attrString, $m, PREG_SET_ORDER);
foreach ($m as $match) {
$attrs[$match[1]] = $match[2];
}
return $attrs;
}
private function renderTag(array $attrs, string $content): string
{
$type = $attrs['type'] ?? 'info';
return "<div class=\"my-tag my-tag--$type\">$content</div>";
}
}
קונפיגורציית התוסף מאוחסנת ב-onTask. השדות public function registerRoutes(): void { $this->grav['router']->addRoute('/api/my-plugin/data', ['GET'], function() { header('Content-Type: application/json'); echo json_encode($this->getPluginData()); exit; }); } , bin/grav plugin my-plugin list-events bin/grav cache:clear , getPluginData() מאפשרים התאמת התנהגות ללא שינויי קוד.
נקודות קצה REST API ובדיקות
עבור נקודות קצה REST API של Grav, רשמו נתיבים דרך אירוע onTask או נתיבים:
public function registerRoutes(): void {
$this->grav['router']->addRoute('/api/my-plugin/data', ['GET'], function() {
header('Content-Type: application/json');
echo json_encode($this->getPluginData());
exit;
});
}בדקו את התוסף דרך CLI:
bin/grav plugin my-plugin list-events bin/grav cache:clear לבדיקות יחידה אנו משתמשים ב-PHPUnit עם אובייקטי Grav מדומים—זה תופס 90% מהבאגים לפני פריסה.
איך להבטיח מטמון נתונים מה-API?
מטמון הוא מפתח בשילוב עם שירותים חיצוניים. בתוסף, השתמשו במטמון המובנה של Grav כפי שמוצג ב-getPluginData(). TTL מוגדר דרך קונפיגורציה. זה מפחית עומס API ומאיץ טעינת עמודים. לדוגמה, עם TTL=3600 שניות, בקשות API יורדות ב-95%, וזמן תגובת העמוד הממוצע יורד ב-300 אלפיות השנייה. מטמון יכול להיות מבוסס קבצים או Redis—הבחירה תלויה בתשתית. אסטרטגיות פסילת מטמון נכונות הן גם קריטיות.
למה תוסף מותאם אישית עדיף על פתרונות JS?
תוסף מותאם אישית מעבד נתונים בצד השרת, משתמש במטמון Grav, ומונע בעיות SEO (תוכן לא מחכה ל-JavaScript). זה מהיר ואמין יותר, במיוחד עם לוגיקה מורכבת. פתרונות JS מגדילים את זמן הטעינה (LCP, INP) ויכולים להיות מושבתים על ידי משתמשים.
| קריטריון | ווידג'ט JS | תוסף מותאם אישית |
|---|---|---|
| השפעה על LCP | +200–500 אלפיות השנייה | 0 (רינדור בצד השרת) |
| אינדוקס SEO | קשה | מלא |
| ניהול מטמון | אין | מטמון Grav מובנה |
| תלות ב-JS | כן | לא |
זה מביא לחיסכון פוטנציאלי של $2400 בשנה בעלויות שרת ו-API.
מה כלול בפיתוח תוסף?
- קוד מקור עם הערות
- קונפיגורציית ברירת מחדל (my-plugin.yaml)
- קבצי לוקליזציה (languages.yaml)
- בדיקות (אם נדרש)
- תיעוד התקנה והגדרה קצר
- אחריות תמיכה לחודש אחד לאחר מסירה
תהליך
- ניתוח דרישות ובחירת אירועים
- פיתוח תוסף עם מחשבה על ביצועים
- שילוב שירות חיצוני ומטמון
- בדיקות על כל העמודים
- פריסה והעברת תיעוד
יש לנו ניסיון של 5+ שנים בפיתוח Grav ומסרנו למעלה מ-30 פרויקטי תוספים מותאמים אישית. הצוות שלנו השלים למעלה מ-100 משימות הקשורות ל-Grav.
לוחות זמנים משוערים
| סוג תוסף | לוח זמנים | עלות טיפוסית |
|---|---|---|
| Shortcode / עיבוד תוכן | 4–12 שעות | $500–$800 |
| שילוב API חיצוני + מטמון | 1–3 ימים | $800–$1,500 |
| טופס מותאם עם עיבוד | 1–2 ימים | $600–$1,200 |
| נקודות קצה REST API (3–5 נתיבים) | 1–2 ימים | $700–$1,300 |
| תוסף פונקציונלי מלא עם ממשק משתמש | 3–7 ימים | $1,500–$3,000 |
העלות המדויקת מחושבת באופן אישי לאחר התקציר.
הזמינו פיתוח תוסף מותאם אישית—נעריך את הפרויקט תוך יום עבודה אחד. צרו קשר כדי לדון במשימה שלכם. קבלו ייעוץ על שילוב או הרחבת פונקציונליות. העריכו את היכולות של Grav—הזמינו פיתוח תוסף היום.







