قوائد مسیریابی پلاگین سیستم (System Plugin Router Rules)

مقدمه

این پلاگین‌ سیستم (system plugin) نشان می‌دهد چطور می‌توانید بدون تغییر در هسته جوملا، رفتار ساخت آدرس‌های SEF در کامپوننت `com_content` را تغییر دهید.

هشدار: پلاگینی که در سطح سیستم است، در همه قسمتها – هم جلوی سایت (frontend) و هم مدیریت (backend) بارگزاری می‌شود. 

اگر کدی در آن اشتباه داشته باشید (مثلاً خطای نحوی PHP) ممکن است دسترسی به مدیریت جوملا از بین برود. برای رفع آن باید به دیتابیس (مثلاً phpMyAdmin) وارد شده و مقدار فیلد `enabled` جدول `#__extensions` مربوط به پلاگین را ۰ کنید. اگر به این کار مسلط نیستید، توصیه می‌کنیم از این پلاگین استفاده نکنید.

زمینه فنی

- جوملا هنگام ساخت URLهای SEF (Friendly URL)، از کلاس `SiteRouter` استفاده می‌کند. 

- این کلاس از قواعدی به نام `MenuRules` استفاده می‌کند تا منوبندی (menuitem) مبنای URL را پیدا کند. 

- منوبندی انتخاب شده روی ساختار URL، نحوه نشان دادن صفحه و ماژول‌های مرتبط تأثیر می‌گذارد. 

- کامپوننت `com_content` هم این قواعد را استفاده می‌کند و ممکن است بخواهید شکل URLهای تولید شده را طبق میل خود تغییر دهید.

راهکار اصلی

- باید کلاس Router مخصوص خود را بنویسید. 

- سپس کاری کنید که `com_content` به جای استفاده از Router پیش‌فرض خود، از Router شما استفاده کند. 

- از آنجا که com_content  مسیریاب(Router) خود را از طریق `RouterFactory` می‌سازد، می‌توان با تزریق namespace یا فضای نام دلخواه به `RouterFactory`، باعث شد که Router ساخته شده از نوع شما باشد نه پیش‌فرض. 

- طبق مستندات، `RouterFactory` کلاس Router را با نام `<namespace>\Site\Service\Router` می‌سازد. 

ساختار پلاگین و فایل‌ها

- پلاگین را در پوشه‌ای مثلاً با نام `plg_custom_menurule` قرار دهید. 

- شامل ۵ فایل زیر است:

1. فایل مانیفست پلاگین 

`plg_custom_menurule/custom_menurule.xml`

 
<?xml version="1.0" encoding="utf-8"?>

<extension type="plugin" group="system" method="upgrade">
  <name>Custom Menurule</name>
  <version>1.0.0</version>
  <creationDate>today</creationDate>
  <author>me</author>
  <description>This plugin overrides the com_content site router where it selects the menuitem for the SEF URL</description>
  <namespace path="src">My\Plugin\System\CustomMenurule</namespace>
  <files>
    <folder plugin="custom_menurule">services</folder>
    <folder>src</folder>
  </files>
</extension>
 

2. فایل Service Provider 

این کد بخش استاندارد و کلیشه‌ای (boilerplate) است که در اکثر پلاگین‌های جوملا وجود دارد تا هنگام بارگذاری پلاگین:

  • پلاگین به صورت خودکار ساخته (instantiate) شود،
  • وابستگی‌هایش (مانند سیستم رویدادها، اپلیکیشن، پارامترها و ...) به صورت خودکار تزریق شود،
  • محیط اجرای پلاگین آماده شود.

شما فقط کافی است این کد را مطابق نام فضای نام (Namespace)، مسیر و نام کلاس پلاگین خودتان تغییر دهید. معمولاً تغییرات اصلی شامل سه خط است:

  • تغییر نام فضای نام (namespace) به فضای نام پلاگین شما.
  • تغییر نام کلاس پلاگین (Plugin class) به کلاس پلاگین شما.
  • تزریق اپلیکیشن جوملا (Application) به پلاگین، چون معمولاً پلاگین به اپلیکیشن نیاز دارد تا مثلاً بتواند وضعیت اجرای جوملا را چک کند یا عملیات مرتبط را انجام دهد.

`plg_custom_menurule/services/provider.php`

 
<?php
use My\Plugin\System\CustomMenurule\Extension\CustomMenurulePlugin;
use Joomla\DI\Container;
use Joomla\DI\ServiceProviderInterface;
use Joomla\CMS\Extension\PluginInterface;
use Joomla\Event\DispatcherInterface;
use Joomla\CMS\Factory;
use Joomla\CMS\Plugin\PluginHelper;

return new class implements ServiceProviderInterface {

    public function register(Container $container) {
        $container->set(
            PluginInterface::class,
            function (Container $container) {
                $dispatcher = $container->get(DispatcherInterface::class);
                $plugin     = new CustomMenurulePlugin(
                    $dispatcher,
                    (array) PluginHelper::getPlugin('system', 'custom_menurule')
                );
                $plugin->setApplication(Factory::getApplication());
                return $plugin;
            }
        );
    }
};
 

3. فایل کلاس پلاگین(Extension class file)

این کلاس `CustomMenurulePlugin` که در فایل  زیر قرار دارد:

`plg_custom_menurule/src/Extension/CustomMenurulePlugin.php` 

نقطه‌ی ورود (Entry Point) پلاگین سیستم شماست.

شرح عملکرد

- پلاگین به عنوان یک Subscriber (مشترک رویدادهای خاص) روی رویداد `onAfterExtensionBoot` گوش می‌کند. 

- این رویداد از طرف هسته جوملا پس از بارگذاری هر پلاگین یا کامپوننت اجرا می‌شود. 

- زمانی که کامپوننت `com_content` بارگذاری می‌شود، پلاگین‌ شما وارد عمل شده و با فراخوانی متد `registerServiceProvider` یک سرویس‌گیرنده (Service Provider) جدید برای `RouterFactory` ثبت می‌کند. 

- در این متد، به جای فضای نام (Namespace) پیش‌فرض com_content، فضای نام سفارشی پلاگین شما (`\My\Plugin\System\CustomMenurule`) به `RouterFactory` تزریق می‌شود. 

- این کار باعث می‌شود وقتی Router کامپوننت `com_content` ساخته می‌شود، به جای Router پیش‌فرض، Router نوشته شده توسط شما ساخته شود — و بدین ترتیب بتوانید نحوه ایجاد آدرس‌های SEF را تغییر دهید.

کد کامل کلاس با توضیحات خط به خط

 
<?php
namespace My\Plugin\System\CustomMenurule\Extension;

defined('_JEXEC') or die;

use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\Event\Event;
use Joomla\Event\SubscriberInterface;
use Joomla\CMS\Extension\ComponentInterface;
use Joomla\CMS\Extension\Service\Provider\RouterFactory;

class CustomMenurulePlugin extends CMSPlugin implements SubscriberInterface {

    // تعریف رویدادهایی که پلاگین به آنها گوش می‌دهد
    public static function getSubscribedEvents(): array {
        return [
            // هنگام رخ دادن رویداد onAfterExtensionBoot متد replaceRouterFactory فراخوانی می‌شود
            'onAfterExtensionBoot' => 'replaceRouterFactory',
        ];
    }

    // متد واکنش به رویداد onAfterExtensionBoot
    public function replaceRouterFactory(Event $event): void {
        // فقط در محیط سایت (فرانت‌اند) اعمال می‌شود، نه مدیریت
        if (!$this->getApplication()->isClient("site")) {
            return;
        }

        // دریافت آرگومان‌های رویداد: موضوع، نوع پلاگین، نام پلاگین، و Container (DIC)
        [$subject, $type, $extensionName, $container] = array_values($event->getArguments());

        // اگر پلاگین بارگذاری شده کامپوننت content باشد
        if (($type === ComponentInterface::class) && ($extensionName === "content")) {
            // جایگزین کردن RouterFactory در Container با فضای نام پلاگین‌ی خودمان
            $container->registerServiceProvider(new RouterFactory('\\My\\Plugin\\System\\CustomMenurule'));
        }
    }
}
 

این کلاس پلاگین موجب می‌شود:

- هنگام بارگذاری کامپوننت محتوای جوملا (`com_content`)، 

- به جای ساخت Router پیش‌فرض، 

- از Router سفارشی تعریف شده در فضای نام پلاگین شما استفاده شود، 

- تا بتوانید قواعد سفارشی در مسیر‌سازی (routing) تعریف کنید.

درنتیجه پلاگین شما به راحتی رفتار URLهای SEF کامپوننت محتوای جوملا را تغییر می‌دهد، بدون نیاز به دستکاری کد هسته جوملا.

4. کامپوننت روتر

چون `RouterFactory` تلاش می‌کند تا یک روتر کامپوننت را با نام کلاس کامل `<namespace>\Site\Service\Router` نمونه‌سازی کند، این یعنی باید نام کلاس روتر و محل فایل PHP آن را تعریف کنیم. ما کلاس روتر خودمان را شبیه به `com_content` می‌سازیم و آن را از کلاس روتر `com_content` مشتق می‌کنیم، اما کلاس MenuRules که به آن متصل می‌شود را به کلاس MenuRules خودمان تغییر می‌دهیم.

همچنین باید تابع `getName` را تعریف کنیم تا رشته `"content"` را برگرداند، چون این رشته برای گرفتن آیتم‌های منوی مرتبط، یعنی آیتم‌هایی که به `com_content` وابسته‌اند، استفاده می‌شود.

فایل: 

`plg_custom_menurule/src/Site/Service/Router.php`

 

 
<?php

namespace My\Plugin\System\CustomMenurule\Site\Service;

use Joomla\CMS\Application\SiteApplication;
use Joomla\CMS\Categories\CategoryFactoryInterface;
use Joomla\CMS\Component\Router\Rules\MenuRules;
use Joomla\CMS\Menu\AbstractMenu;
use Joomla\Database\DatabaseInterface;

\defined('_JEXEC') or die;

class Router extends \Joomla\Component\Content\Site\Service\Router
{
    public function __construct(SiteApplication $app, AbstractMenu $menu, CategoryFactoryInterface $categoryFactory, DatabaseInterface $db)
    {
        // اجرای سازنده روتر com_content
        parent::__construct($app, $menu, $categoryFactory, $db);

        // جدا کردن MenuRules که در سازنده com_content تعریف شده بود
        $rules = $this->getRules();
        foreach ($rules as $rule) {
            if ($rule instanceof \Joomla\CMS\Component\Router\Rules\MenuRules) {
                $this->detachRule($rule);
                break;
            }
        }
        
        // و متصل کردن MenuRules خودمان
        $this->attachRule(new \My\Plugin\System\CustomMenurule\Site\Service\MenuRules($this));
    }
    
    public function getName()
    {
        return "content";
    }
}
 

5. کلاس MenuRules

حالا می‌توانیم قوانین خود برای تعیین اینکه روی کدام آیتم منو URLهای SEF کامپوننت `com_content` بنا شود را بنویسیم. کد زیر نمونه‌ای است که چطور می‌توانید کد جوملا در `libraries/src/Component/Router/Rules/MenuRules.php` را اصلاح کنید. چون کلاس قوانین ما از کلاس قوانین جوملا ارث‌بری می‌کند، می‌توانید تابع `preprocess` را سفارشی کنید و اگر آیتم منوی مناسب پیدا نشد، با فراخوانی `parent::preprocess(&$query)` به نسخه اصلی جوملا برگردید.

این تابع نسبت به روتر استاندارد جوملا در چند مورد تفاوت دارد:

- اگر `Itemid` در فراخوانی `Route::_()` تنظیم شده باشد و آیتم منو به `com_content` مرتبط باشد، از آن استفاده می‌شود.

- اگر سایت چندزبانه باشد، ورودی مرتبط با صفحه اصلی زبان `"*"` از آرایه جستجو حذف می‌شود. این مربوط به زمانی است که برای هر زبان صفحه اصلی خاصی تعیین کرده‌اید و ماژول منوی اصلی را منتشر نکرده‌اید. حذف این ورودی از بروز خطا در مسیر‌یابی جلوگیری می‌کند؛ مثلاً اگر صفحه اصلی `"*"` به یک مقاله و صفحه اصلی زبان خاص به مقاله دیگری اشاره داشته باشد.

- جدول جستجو را بررسی می‌کند تا تطابق دقیق پارامترهای مشخص شده در `Route::_()` با آیتم منوی سایت را پیدا کند و در صورت موفقیت، از `Itemid` آن استفاده می‌کند.

- اگر صفحه فعلی (آیتم منوی فعال) مربوط به `com_content` باشد، از `Itemid` آن استفاده می‌کند.

اگر هیچ‌کدام این موارد منجر به یافتن آیتم منوی مناسب نشود، تابع به کد استاندارد جوملا برمی‌گردد.

plg_custom_menurule/src/Site/Service/MenuRules.php

 
namespace My\Plugin\System\CustomMenurule\Site\Service;

use Joomla\CMS\Component\Router\Rules\RulesInterface;
use Joomla\CMS\Component\ComponentHelper;
use Joomla\CMS\Component\Router\RouterView;
use Joomla\CMS\Language\Multilanguage;

\defined('JPATH_PLATFORM') or die;

class MenuRules extends \Joomla\CMS\Component\Router\Rules\MenuRules
{
    private static $allLangHomeRemoved = false;

    public function preprocess(&$query)
    {
        $active = $this->router->menu->getActive();

        /**
         * اگر شناسه آیتم فعال برابر شناسه آیتم ارسال شده نباشد
         * یا شناسه آیتم ارسال شده باشد ولی آیتم فعال وجود نداشته باشد،
         * صرفاً از آیتم ارسالی استفاده و ادامه می‌دهیم.
         */
        if (isset($query['Itemid']) && ($active === null || $query['Itemid'] != $active->id)) {
            return;
        }

        // گرفتن زبان از query
        $language = isset($query['lang']) ? $query['lang'] : '*';

        // اگر چندزبانه فعال است و زبان "*" است، زبان جاری سایت را جایگزین می‌کنیم
        if (Multilanguage::isEnabled() && $language === '*') {
            $language = $this->router->app->get('language');
        }

        // ساخت جدول جستجو معکوس برای زبان
        // توجه: ساخت lookup برای "*" در سازنده قبلاً انجام شده
        if (!isset($this->lookup[$language])) {
            $this->buildLookup($language);
        }
        
        // اگر آیتم منو (Itemid) در query مشخص شده و در جدول lookup است،
        // مستقیماً از آن استفاده می‌کنیم
        if (isset($query['Itemid'])) {
            if (array_search((int)$query['Itemid'], $this->lookup, true) !== false) {
                return; // استفاده مستقیم از این Itemid
            }
        }

        /*
         بخش کد غیرضروری که بررسی تطابق فعال و query را انجام می‌داد، نظر گرفته شده.
        */

        // اگر سایت چندزبانه است و صفحه خانه عمومی "*" هنوز حذف نشده است
        // آن را از جدول lookup حذف می‌کنیم تا مشکل مسیر‌یابی پیش نیاید.
        if (Multilanguage::isEnabled() && !self::$allLangHomeRemoved) {
            $homeItems = $this->router->menu->getItems(array('language', 'home'), array('*', 1));
            if ($homeItems) {
                $allLangHome = $homeItems[0]->id;
                foreach ($this->lookup as $lang => $viewArray) {
                    foreach ($viewArray as $view => $idArray) {
                        foreach ($idArray as $id => $itemid) {
                            if ($itemid == $allLangHome) {
                                if (count($this->lookup[$lang][$view]) == 1) {
                                    unset($this->lookup[$lang][$view]);
                                } else {
                                    unset($this->lookup[$lang][$view][$id]);
                                }
                                break;
                            }
                        }
                    }
                }
            }
            self::$allLangHomeRemoved = true;
        }
        
        // ساخت کلید جستجو به شکل view:layout و جستجوی آن در lookup
        if (isset($query['view'])) {
            $searchKey = $query['view'];
            if (isset($query['layout']) && $query['layout'] !== 'default') {
                $searchKey .= ":" . $query['layout'];
            }
            foreach ($this->lookup as $lang => $arr) {
                if (array_key_exists($searchKey, $arr)) {
                    $matchingViews = $arr[$searchKey];
                    // اگر id مشخص شده باشد، به دنبال تطابق دقیق می‌گردیم
                    if (isset($query['id'])) {
                        $idKey = (int) $query['id'];
                        if (array_key_exists($idKey, $matchingViews)) {
                            $query['Itemid'] = $matchingViews[$idKey];
                            return;
                        } 
                    } else { // اگر شناسه‌ای نیست
                        if (array_key_exists(0, $matchingViews)) {
                            $query['Itemid'] = $matchingViews[0];
                            return;
                        } 
                    }
                }
            }
        }
        
        // در صورتی که آیتم منوی فعال مربوط به com_content باشد از آن استفاده می‌کنیم
        if ($active && $active->component === "com_content") {
            $query["Itemid"] = $active->id;
            return;
        }
        
        // اگر تاکنون هیچ آیتم مناسبی پیدا نشد، به کد پیش‌فرض جوملا مراجعه می‌کنیم

        $needles = $this->router->getPath($query);

        $layout = isset($query['layout']) && $query['layout'] !== 'default' ? ':' . $query['layout'] : '';

        if ($needles) {
            foreach ($needles as $view => $ids) {
                $viewLayout = $view . $layout;

                if ($layout && isset($this->lookup[$language][$viewLayout])) {
                    if (\is_bool($ids)) {
                        $query['Itemid'] = $this->lookup[$language][$viewLayout];
                        return;
                    }

                    foreach ($ids as $id => $segment) {
                        if (isset($this->lookup[$language][$viewLayout][(int) $id])) {
                            $query['Itemid'] = $this->lookup[$language][$viewLayout][(int) $id];
                            return;
                        }
                    }
                }

                if (isset($this->lookup[$language][$view])) {
                    if (\is_bool($ids)) {
                        $query['Itemid'] = $this->lookup[$language][$view];
                        return;
                    }

                    foreach ($ids as $id => $segment) {
                        if (isset($this->lookup[$language][$view][(int) $id])) {
                            $query['Itemid'] = $this->lookup[$language][$view][(int) $id];
                            return;
                        }
                    }
                }
            }
        }

        // بررسی تطابق آیتم منوی فعال با زبان درخواست شده
        if (
            $active && $active->component === 'com_' . $this->router->getName()
            && ($language === '*' || \in_array($active->language, ['*', $language]) || !Multilanguage::isEnabled())
        ) {
            $query['Itemid'] = $active->id;
            return;
        }

        // در نهایت اگر هیچ آیتم مناسب یافت نشد، آیتم خانه مخصوص همان زبان را قرار می‌دهیم
        $default = $this->router->menu->getDefault($language);

        if (!empty($default->id)) {
            $query['Itemid'] = $default->id;
        }
    }
}
 

توضیحات کلی:

- این کلاس از کلاس `MenuRules` جوملا ارث می‌برد و وظیفه اصلی آن تعیین `Itemid` مناسب برای مسیر‌یابی لینک‌ها در کامپوننت `com_content` است.

- تابع `preprocess` ورودی کوئری (یعنی پارامترهای URL) را دریافت و تلاش می‌کند `Itemid` مناسب را برای لینک بر اساس معیارهای مختلف تعیین کند.

- مواردی مانند زبان سایت، آیتم منوی فعال، و وجود آیتم‌ها در جدول lookup بررسی می‌شود.

- در سایت‌های چندزبانه، صفحه خانه عمومی حذف می‌شود تا مسیر‌یابی به درستی انجام شود.

- اگر آیتم منوی مناسب یافت نشد، از نسخه اصلی جوملا کمک گرفته و در نهایت آیتم خانه زبان مربوطه انتخاب می‌شود.

نصب

پس از ایجاد فایل‌های فوق، پوشه مربوطه را فشرده (zip) کنید و پلاگین را نصب نمایید. سپس به منوی System / Plugins یا System / Extensions بروید و پلاگین را فعال کنید. حالا می‌توانید با آیتم‌های منوی `com_content`، دسته‌بندی‌ها و مقالات آزمایش کنید و تفاوت نمایش URLهای SEF را مشاهده کنید.