” توی این آموزش، قدمبهقدم یک سیستم چندپوستهای در لاراول میسازیم که امکان تعریف، مدیریت و تغییر پوستههای سایت رو از طریق پنل مدیریت فراهم میکنه. “
احتمالاً با وردپرس آشنا هستید و میدونید که میشه براش پوستههای مختلفی ساخت یا از پوستههای آمادهای که بقیه توسعهدهندهها منتشر کردن استفاده کرد.
هر پوسته ظاهر متفاوتی به سایت میده و میتونیم بدون تغییر محتوای سایت، از طریق پنل مدیریت بین پوستهها جابهجا بشیم. از طرف دیگه، وردپرس سیستم افزونهها رو هم داره که با استفاده از اونها میشه قابلیتهای مختلفی به سایت اضافه کرد.
ترکیب این دو سیستم، امکان ساخت سایتهای متنوعی رو فراهم میکنه؛ از فروشگاه اینترنتی گرفته تا سایتهای آموزشی و شرکتی و..
حالا فرض کنیم بخوایم همچین ساختاری رو در یک پروژه لاراول پیاده کنیم.
میتونیم پوستهها رو مسئول ظاهر سایت در نظر بگیریم و برای قابلیتهای مستقل، سراغ ساختار ماژولار یا سیستم پلاگینها بریم. اینجوری هر بخش وظیفه مشخصی داره و میتونیم ظاهر سایت رو بدون وابسته کردن اون به منطق اصلی برنامه تغییر بدیم.
اما برای ساخت چنین سیستمی، صرفاً ایجاد چند پوشه برای پوستهها کافی نیست. باید مشخص کنیم لاراول چطور پوستهها رو شناسایی کنه، اطلاعات هر پوسته رو از کجا بخونه، پوسته فعال رو چطور تشخیص بده و viewهای مربوط به اون رو چطور نمایش بده.
تو این مقاله، قدمبهقدم یک سیستم چندپوستهای برای لاراول پیاده میکنیم. در پایان، پوستههای مختلفی داریم و میتونیم از طریق پنل مدیریت، پوسته موردنظر رو انتخاب کنیم و ظاهر سایت رو تغییر بدیم.
البته چیزی که پیاده میکنیم، یک نمونه سادهست و هدف اصلی اینه که با ایده و روش پیادهسازی چنین سیستمی آشنا بشیم، نه اینکه یک سیستم کامل و آماده استفاده در پروژههای بزرگ بسازیم :)
توی این پروژه، فایلهای مربوط به هر پوسته رو از بقیه بخشهای برنامه جدا میکنیم تا مدیریت و توسعه هر پوسته مستقل از پوستههای دیگه باشه.
برای این کار، یک پوشه به اسم themes در ریشه پروژه داریم و هر پوسته پوشه مخصوص خودش رو داره؛
مثلاً themes/starter برای پوسته starter و themes/minimal برای پوسته minimal.
ساختار کلی پروژه به این شکل میشه:
project/
├── app/
│ ├── Http/Controllers/
│ │ └── Admin/ThemeController.php
│ ├── Models/
│ │ └── Setting.php
│ ├── Providers/
│ │ └── ThemeServiceProvider.php
│ └── Support/
│ └── ThemeManager.php
├── config/
│ └── themes.php
├── database/
│ └── migrations/
├── resources/
│ └── views/
│ └── themes/
│ └── fallback.blade.php
├── routes/
│ └── web.php
├── themes/
│ ├── starter/
│ │ ├── theme.json
│ │ ├── vite.config.js
│ │ ├── assets/
│ │ │ ├── css/app.css
│ │ │ ├── js/app.js
│ │ │ ├── images/
│ │ │ └── fonts/
│ │ └── views/
│ │ ├── layouts/app.blade.php
│ │ └── pages/home.blade.php
│ └── minimal/
│ ├── theme.json
│ ├── vite.config.js
│ ├── assets/
│ └── views/
└── public/
└── themes/
├── starter/
│ └── build/
└── minimal/
└── build/هر پوسته ظاهر و فایلهای مخصوص خودش رو داره. مثلاً اگه بخوایم رنگها، چیدمان یا بخشهای دیگه پوسته starter رو تغییر بدیم، فایلهای مربوط به همون پوسته رو ویرایش میکنیم. همینطور بعد از تغییر فایلهای css و js، با build گرفتن نسخه جدید رو میسازیم و..
خروجی build تو مسیر public/themes/{slug}/build قرار میگیره. اینجوری فایلهای اصلی که موقع توسعه باهاشون کار میکنیم از فایلهای نهایی که مرورگر بارگذاری میکنه جدا هستن.
توی مثالی که با هم پیاده میکنیم، دو پوسته به نامهای starter و minimal داریم که ظاهر متفاوتی دارن. با استفاده از این دو پوسته، میتونیم تغییر پوسته فعال سایت رو از طریق پنل مدیریت بررسی کنیم.
هر پوسته باید اطلاعات مشخصی داشته باشه؛ مثلاً نام، توضیحات، نسخه و فایلهای ورودی. این اطلاعات رو داخل فایل theme.json قرار میدیم.
فایل themes/starter/theme.json رو میسازیم و محتوای زیر رو داخلش قرار میدیم:
{
"name": "Starter",
"slug": "starter",
"description": "A simple starter theme for the website.",
"version": "1.0.0",
"author": "SpaceDev",
"preview": "assets/images/cover.jpg",
"requires": {
"laravel": "^13.0"
},
"views": {
"home": "pages.home"
},
"assets": {
"css": "assets/css/app.css",
"js": "assets/js/app.js"
}
}این فایل رو میتونیم بهعنوان مشخصات پوسته در نظر بگیریم. برای مثال، name و description در پنل مدیریت نمایش داده میشن و slug شناسهایه که در مسیر پوشهها و آدرس فایلها ازش استفاده میکنیم.
فیلد views هم مشخص میکنه صفحه اصلی پوسته از کدوم view استفاده میکنه. اینجوری لازم نیست نام فایلهای داخلی پوسته رو در بخشهای مختلف برنامه تکرار کنیم.
فعلاً مقدار requires رو در نظر نمیگیریم و بررسیش نمیکنیم؛ اینجا فقط میخوام ببینید که میشه اطلاعاتی مثل نسخه لاراول موردنیاز رو هم برای هر پوسته تعریف کرد و .. توی یک سیستم کاملتر، بررسی این موارد میتونه برای اطمینان از سازگاری پوسته با پروژه اهمیت داشته باشه..
حالا که ساختار پوشهها مشخص شده، باید مسیر پوستهها، محل ذخیره فایلهای خروجی و پوسته پیشفرض رو هم تعریف کنیم.
برای این کار، فایل config/themes.php رو میسازیم و کد زیر رو داخلش قرار میدیم:
<?php
return [
'path' => base_path('themes'),
'public_path' => public_path('themes'),
'public_url' => 'themes',
'default' => 'starter',
'fallback_view' => 'themes.fallback',
];این فایل تنظیمات اصلی سیستم پوستهها رو نگه میداره. مثلاً مقدار path مسیر پوشهایه که پوستهها داخلش قرار دارن و default مشخص میکنه اگه پوسته فعالی انتخاب نشده بود، کدوم پوسته بهعنوان پیشفرض استفاده بشه.
اینجوری مسیرها و تنظیمات مربوط به پوستهها رو یکجا نگه میداریم و هر بخش از برنامه که به اونها نیاز داشته باشه، میتونه از همین فایل استفاده کنه.
باید جایی داشته باشیم که مشخص کنه در حال حاضر کدوم پوسته برای نمایش سایت انتخاب شده. این اطلاعات رو در دیتابیس ذخیره میکنیم؛ چون انتخاب پوسته یک تنظیم مربوط به خود سایت محسوب میشه، نه تنظیمی که صرفاً در فایلهای پروژه قرار داشته باشه.
یک migration برای جدول تنظیمات میسازیم:
php artisan make:migration create_settings_tableدر فایل migrationی که ایجاد شده، متد up رو به این شکل تغییر میدیم:
public function up(): void
{
Schema::create('settings', function (Blueprint $table) {
$table->string('key')->primary();
$table->text('value')->nullable();
$table->timestamps();
});
}حالا migration رو اجرا میکنیم:
php artisan migrateبرای کار با این جدول، مدل Setting رو هم میسازیم.
فایل app/Models/Setting.php رو با کد زیر تکمیل میکنیم:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Setting extends Model
{
protected $primaryKey = 'key';
public $incrementing = false;
protected $keyType = 'string';
protected $fillable = [
'key',
'value',
];
}برای این پروژه، جدول تنظیمات فقط یک مقدار با کلید active_theme لازم داره. وقتی مدیر سایت پوستهای رو انتخاب میکنه، مقدار این کلید تغییر میکنه.
تا اینجا ساختار پوستهها و محل ذخیره تنظیمات رو آماده کردیم. حالا باید کلاسی بنویسیم که کارهای مربوط به پوستهها رو مدیریت کنه.
این کلاس مسئول چند کاره:
پیدا کردن پوستههای موجود و خوندن اطلاعات اونها
تشخیص پوسته فعال
بررسی آماده بودن پوسته برای استفاده
تغییر پوسته فعال
انتخاب view مناسب
تولید تگهای css و js از روی فایل manifest
فایل app/Support/ThemeManager.php رو با کد زیر میسازیم:
<?php
namespace App\Support;
use App\Models\Setting;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\View;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Illuminate\Support\HtmlString;
use RuntimeException;
class ThemeManager
{
protected ?array $themes = null;
public function all(): array
{
if ($this->themes !== null) {
return $this->themes;
}
$this->themes = [];
$directory = config('themes.path');
if (! File::isDirectory($directory)) {
return $this->themes;
}
foreach (File::directories($directory) as $path) {
$manifestPath = $path . '/theme.json';
if (! File::exists($manifestPath)) {
continue;
}
$manifest = json_decode(
File::get($manifestPath),
true
);
if (! is_array($manifest)) {
continue;
}
$slug = basename($path);
if (
! isset($manifest['slug']) ||
$manifest['slug'] !== $slug ||
! preg_match('/^[a-z0-9][a-z0-9_-]*$/', $slug)
) {
continue;
}
$manifest['_path'] = $path;
$manifest['_views_path'] = $path . '/views';
$manifest['_preview_url'] = isset($manifest['preview'])
? asset(
config('themes.public_url') . '/'
. $slug . '/'
. ltrim($manifest['preview'], '/')
)
: null;
$manifest['_has_views'] = File::isDirectory(
$manifest['_views_path']
);
$manifest['_built'] = File::exists(
config('themes.public_path')
. '/' . $slug
. '/build/manifest.json'
);
$this->themes[$slug] = $manifest;
}
return $this->themes;
}
public function find(string $slug): ?array
{
return $this->all()[$slug] ?? null;
}
public function activeSlug(): string
{
$slug = Setting::query()
->whereKey('active_theme')
->value('value');
if ($slug && $this->isUsable($slug)) {
return $slug;
}
$default = config('themes.default');
if ($this->isUsable($default)) {
return $default;
}
return 'fallback';
}
public function active(): ?array
{
return $this->find($this->activeSlug());
}
public function isUsable(string $slug): bool
{
$theme = $this->find($slug);
if (! $theme || empty($theme['_has_views'])) {
return false;
}
if (empty($theme['_built'])) {
return false;
}
$homeView = data_get($theme, 'views.home');
if (
! is_string($homeView) ||
! View::exists(
'theme::' . $homeView
)
) {
$path = $theme['_views_path'] . '/'
. str_replace('.', '/', (string) $homeView)
. '.blade.php';
if (! File::exists($path)) {
return false;
}
}
return true;
}
public function activate(string $slug): void
{
if (! $this->find($slug)) {
throw ValidationException::withMessages([
'theme' => 'پوسته موردنظر پیدا نشد.',
]);
}
if (! $this->isUsable($slug)) {
throw ValidationException::withMessages([
'theme' => 'این پوسته آماده استفاده نیست. '
. 'ساختار Viewها و فایلهای Build را بررسی کنید.',
]);
}
Setting::query()->updateOrCreate(
['key' => 'active_theme'],
['value' => $slug]
);
}
public function render(string $view, array $data = [])
{
$slug = $this->activeSlug();
$theme = $this->find($slug);
if ($theme && $this->isUsable($slug)) {
$viewPath = $theme['_views_path'] . '/'
. str_replace('.', '/', $view)
. '.blade.php';
if (File::exists($viewPath)) {
return view('theme::' . $view, $data);
}
}
return view(
config('themes.fallback_view'),
$data
);
}
public function assetTags(): HtmlString
{
$slug = $this->activeSlug();
if ($slug === 'fallback') {
return new HtmlString('');
}
$manifestPath = config('themes.public_path')
. '/' . $slug
. '/build/manifest.json';
if (! File::exists($manifestPath)) {
return new HtmlString('');
}
$manifest = json_decode(
File::get($manifestPath),
true
);
if (! is_array($manifest)) {
return new HtmlString('');
}
$cssEntry = $manifest['assets/css/app.css'] ?? null;
$jsEntry = $manifest['assets/js/app.js'] ?? null;
$baseUrl = asset(
config('themes.public_url')
. '/' . $slug . '/build'
);
$tags = [];
$cssFiles = [];
if (is_array($cssEntry)) {
if (! empty($cssEntry['file'])) {
$cssFiles[] = $cssEntry['file'];
}
foreach (($cssEntry['css'] ?? []) as $file) {
$cssFiles[] = $file;
}
}
if (is_array($jsEntry)) {
foreach (($jsEntry['css'] ?? []) as $file) {
$cssFiles[] = $file;
}
}
foreach (array_unique($cssFiles) as $file) {
$tags[] = '<link rel="stylesheet" href="'
. e($baseUrl . '/' . ltrim($file, '/'))
. '">';
}
if (is_array($jsEntry) && ! empty($jsEntry['file'])) {
$tags[] = '<script type="module" src="'
. e($baseUrl . '/' . ltrim($jsEntry['file'], '/'))
. '"></script>';
}
return new HtmlString(implode("\n", $tags));
}
}این کلاس هسته اصلی سیستم پوستههاست. برای مثال، متد all() فایل theme.json هر پوشه رو میخونه و اطلاعات پوسته رو برمیگردونه. متد activate() هم قبل از ذخیره انتخاب مدیر سایت، بررسی میکنه که پوسته وجود داشته باشه و فایلهای لازم برای نمایش اون آماده باشن.
نکته مهم اینه که نام پوشه و مقدار slug در فایل theme.json باید یکسان باشن. این موضوع کمک میکنه یک پوسته نتونه با معرفی شناسهای متفاوت، مسیرهای فایل پوسته دیگری رو هدف بگیره.
همچنین assetTags() بهجای حدس زدن نام فایلهای خروجی، از manifest.json استفاده میکنه. چون vite معمولاً نام فایلهای نهایی رو همراه با هش تولید میکنه، نمیتونیم فرض کنیم فایل خروجی همیشه app.css یا app.js نام داره.
توی لاراول، وقتی میخوایم یک view رو نمایش بدیم، معمولاً مسیرش رو با نامهایی مثل pages.home مشخص میکنیم. اما توی سیستم ما، هر پوسته viewهای خودش رو داره و لاراول باید بدونه این فایلها رو از کجا پیدا کنه.
برای این کار، از یک namespace اختصاصی به اسم theme استفاده میکنیم.
مثلاً وقتی مینویسیم theme::pages.home، منظورمون فایل pages/home.blade.php در پوشه viewهای پوسته فعاله. اینجوری لازم نیست مسیر فایلهای هر پوسته رو مستقیماً توی کد بنویسیم.
برای معرفی این namespace به لاراول، یک Service Provider میسازیم:
php artisan make:provider ThemeServiceProviderدر نسخههای جدید لاراول، Providerهای جدید معمولاً بهصورت خودکار در فایل bootstrap/providers.php ثبت میشن. اگه نام Provider به این فایل اضافه نشده بود، باید خودمون اون رو به آرایه موجود اضافه کنیم.
حالا فایل app/Providers/ThemeServiceProvider.php رو با کد زیر تکمیل میکنیم:
<?php
namespace App\Providers;
use App\Support\ThemeManager;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\View;
use Illuminate\Support\ServiceProvider;
class ThemeServiceProvider extends ServiceProvider
{
public function boot(ThemeManager $themes): void
{
$slug = $themes->activeSlug();
$theme = $themes->find($slug);
if ($theme && $themes->isUsable($slug)) {
View::addNamespace(
'theme',
$theme['_views_path']
);
Blade::anonymousComponentPath(
$theme['_views_path'],
'theme'
);
}
}
}اینجا ابتدا پوسته فعال رو پیدا میکنیم و بعد مسیر پوشه views اون رو به namespace با نام theme اختصاص میدیم. اگه پوسته قابل استفاده باشه، لاراول میتونه viewهای اون رو با همین namespace پیدا کنه.
برای مثال، این کد:
return view('theme::pages.home');فایل pages/home.blade.php رو از پوشه viewهای پوسته فعال نمایش میده.
از اونجا که این مسیر هنگام راهاندازی برنامه ثبت میشه، با تغییر پوسته فعال، درخواستهای بعدی از مسیر viewهای پوسته جدید استفاده میکنن.
حالا باید پوستهای داشته باشیم که واقعاً قابل استفاده و نمایش باشه.
در پوشه themes/starter فایلهای زیر رو میسازیم:
assets/css/app.css
assets/js/app.js
views/layouts/app.blade.php
views/pages/home.blade.php
vite.config.js
اگه vite از قبل توی پروژه نصب شده، نیازی به نصب دوبارهش نیست. در غیر این صورت، میتونیم با دستور زیر وابستگیهای موردنیاز رو نصب کنیم:
npm install -D vite tailwindcss @tailwindcss/viteتو این مثال از tailwindcss استفاده میکنیم.
فایل themes/starter/vite.config.js رو با تنظیمات زیر میسازیم:
import { cpSync, existsSync } from "node:fs";
import { resolve } from "node:path";
import { defineConfig } from "vite";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
root: resolve(import.meta.dirname),
base: "/themes/starter/build/",
plugins: [
tailwindcss(),
{
name: "copy-theme-images",
closeBundle() {
const from = resolve(import.meta.dirname, "assets/images");
const to = resolve(import.meta.dirname, "../../public/themes/starter/assets/images");
if (existsSync(from)) {
cpSync(from, to, { recursive: true });
}
},
},
],
build: {
outDir: resolve(
import.meta.dirname,
"../../public/themes/starter/build"
),
emptyOutDir: true,
manifest: "manifest.json",
rollupOptions: {
input: {
"css/app": resolve(
import.meta.dirname,
"assets/css/app.css"
),
"js/app": resolve(
import.meta.dirname,
"assets/js/app.js"
),
},
},
},
});این تنظیمات به vite میگه فایلهای ورودی رو از پوشه همین پوسته بخونه و خروجی رو در مسیر عمومی مربوط به starter قرار بده.
فایل themes/starter/assets/css/app.css رو باز میکنیم و کد زیر رو داخلش مینویسیم:
@import "tailwindcss" source(none);
@source "../../views/**/*.blade.php";با @source به تیلویند میگیم فایلهای view این پوسته رو هم بررسی کنه تا کلاسهای استفادهشده در اونها رو شناسایی کنه و css مربوط بهشون رو بسازه.
فایل themes/starter/assets/js/app.js رو باز میکنیم و فعلاً کد زیر رو داخلش قرار میدیم:
document.addEventListener('DOMContentLoaded', () => {
console.log('Starter theme loaded.');
});بعداً میتونیم تعاملات مخصوص همین پوسته رو در این فایل اضافه کنیم.
برای ساخت خروجی، دستور زیر رو از ریشه پروژه اجرا میکنیم:
npx vite build --config themes/starter/vite.config.jsبعد از اجرای موفق، فایلهای خروجی باید در این مسیر قرار بگیرن:
public/themes/starter/build/این پوشه شامل فایل manifest.json و فایلهای نهایی css و js هست. هر بار که در زمان توسعه فایلهای ورودی رو تغییر میدیم، باید دوباره دستور build رو اجرا کنیم تا خروجیها بهروز بشن. هنگام انتشار نسخه جدید پروژه هم باید خروجی پوستهها رو آماده کنیم.
نکته: دستور build رو هنگام فعالسازی پوسته یا در جریان درخواستهای سایت اجرا نمیکنیم. این کار به Node.js و وابستگیهای توسعه نیاز داره، بنابراین خروجی پوسته رو جداگانه و هنگام توسعه یا انتشار آماده میکنیم.
فایل themes/starter/views/layouts/app.blade.php رو میسازیم و کد زیر رو داخلش قرار میدیم:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ $title ?? 'Starter Theme' }}</title>
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600&display=swap" rel="stylesheet">
<style>
body {
font-family: Geist, ui-sans-serif, system-ui, sans-serif;
}
</style>
{!! app(\App\Support\ThemeManager::class)->assetTags() !!}
</head>
<body class="bg-black text-neutral-300 antialiased">
<div class="flex flex-col min-h-screen">
<header class="border-b border-neutral-800">
<div class="w-full max-w-5xl px-6 mx-auto">
<div class="flex items-center gap-x-10 h-14">
<div class="flex items-center gap-2 font-semibold text-white">
{{ config('app.name') }}
</div>
<nav class="flex gap-6 text-sm text-neutral-400">
<a href="#" class="hover:text-white">Docs</a>
<a href="#" class="hover:text-white">Blog</a>
<a href="#" class="hover:text-white">GitHub</a>
</nav>
</div>
</div>
</header>
<main class="flex-1">
{{ $slot }}
</main>
<footer class="border-t border-neutral-800 py-8 text-center font-mono text-xs text-neutral-500 mt-10">
© {{ date('Y') }} {{ config('app.name') }}
</footer>
</div>
</body>
</html>حالا فایل themes/starter/views/pages/home.blade.php رو میسازیم. این صفحه از Layout پوسته استفاده میکنه و محتوای اصلی رو داخل اون قرار میده:
<x-theme::layouts.app title="Starter Theme">
<section class="relative overflow-hidden">
<div class="absolute inset-0"
style="background-image: linear-gradient(to right,#ffffff12 1px,transparent 1px),linear-gradient(to bottom,#ffffff12 1px,transparent 1px);
background-size: 64px 64px;
mask-image: radial-gradient(ellipse 70% 70% at 50% 0%, #000 40%, transparent 100%);
-webkit-mask-image: radial-gradient(ellipse 70% 70% at 50% 0%, #000 40%, transparent 100%);"></div>
<div class="w-full max-w-5xl px-6 mx-auto">
<div class="relative py-28 text-center">
<span class="inline-flex items-center gap-2 rounded-full border border-neutral-800 bg-neutral-950 px-3 py-1 font-mono uppercase text-xs text-neutral-400">
<span class="size-1.5 rounded-full bg-emerald-400"></span> Theme: Starter
</span>
<h1 class="mt-8 text-white text-5xl font-semibold tracking-tighter sm:text-6xl">
Develop. Preview.<br>
Ship with Laravel.
</h1>
<p class="mx-auto mt-6 max-w-xl text-neutral-400">
This page is rendered by the active theme. Switch themes and only the views change.
</p>
<div class="mt-10 flex justify-center gap-3">
<a href="#" class="rounded-md bg-white px-4 py-2 text-sm font-medium text-black hover:bg-neutral-200">Get Started</a>
<a href="#" class="rounded-md border border-neutral-800 px-4 py-2 text-sm font-medium text-white hover:bg-neutral-900">Documentation</a>
</div>
</div>
</div>
</section>
<section>
<div class="w-full max-w-5xl px-6 mx-auto">
<div class="grid sm:grid-cols-3 border border-neutral-800 divide-y divide-neutral-800 sm:divide-x sm:divide-y-0">
@foreach ([
['Routing', 'Define routes once, serve them with any theme.'],
['Blade', 'Views are resolved from the active theme first.'],
['Eloquent', 'Your data layer never knows the theme exists.'],
] as [$title, $text])
<div class="p-8">
<h3 class="font-medium text-white">{{ $title }}</h3>
<p class="mt-2 text-sm leading-6 text-neutral-400">{{ $text }}</p>
</div>
@endforeach
</div>
</div>
</section>
</x-theme::layouts.app>در اینجا Layout اصلی سایت داخل خود پوسته قرار داره. هر پوسته میتونه ساختار HTML، استایلها و اجزای ظاهری متفاوتی داشته باشه..
ممکنه پوشه پوسته فعال حذف بشه یا فایلهای لازم اون به هر دلیلی در دسترس نباشن. بهتره در چنین شرایطی سایت بهجای خطای view، یک صفحه ساده نمایش بده.
فایل resources/views/themes/fallback.blade.php رو میسازیم:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ config('app.name') }}</title>
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600&display=swap" rel="stylesheet">
<style>
body {
font-family: Geist, ui-sans-serif, system-ui, sans-serif;
}
</style>
</head>
<body class="bg-white text-neutral-600 antialiased">
<div class="flex flex-col min-h-screen">
<header>
<div class="w-full max-w-3xl px-6 mx-auto">
<div class="flex items-center h-16 font-semibold text-black">
{{ config('app.name') }}
</div>
</div>
</header>
<main class="flex-1 flex flex-col items-center justify-center">
<div class="w-full max-w-3xl px-6 mx-auto">
<div class="py-24">
<span class="font-mono text-xs uppercase tracking-widest text-neutral-400">
Fallback
</span>
<h1 class="mt-6 text-4xl font-semibold tracking-tight text-black sm:text-5xl">
Your website is<br>
getting ready.
</h1>
<p class="mt-6 max-w-lg leading-7 text-neutral-500">
The current theme is not available. You can pick a ready theme from the admin panel.
</p>
<div class="mt-10">
<a href="/admin/themes"
class="rounded-md bg-black px-4 py-2 text-sm font-medium text-white hover:bg-neutral-800">
Open admin panel
</a>
</div>
</div>
</div>
</main>
<footer class="mt-10 border-t border-neutral-200 py-8 text-center font-mono text-xs text-neutral-400">
© {{ date('Y') }} {{ config('app.name') }}
</footer>
</div>
</body>
</html>این view به فایلهای پوسته وابسته نیست و اگه مشکلی برای پوستهها پیش بیاد، هنوز یک صفحه جایگزین داریم.
برای اینکه صفحه اصلی از پوسته فعال استفاده کنه، یک کنترلر ساده ایجاد میکنیم:
php artisan make:controller HomeControllerفایل app/Http/Controllers/HomeController.php رو با کد زیر میسازیم:
<?php
namespace App\Http\Controllers;
use App\Support\ThemeManager;
class HomeController extends Controller
{
public function __invoke(ThemeManager $themes)
{
return $themes->render('pages.home');
}
}متد render() در ThemeManager بررسی میکنه که view صفحه اصلی در پوسته فعال وجود داشته باشه. اگر وجود داشت، همون view رو برمیگردونه؛ در غیر این صورت، صفحه جایگزین نمایش داده میشه.
مسیر صفحه اصلی رو در فایل routes/web.php تعریف میکنیم تا درخواستهای صفحه اصلی به HomeController ارسال بشن:
use App\Http\Controllers\HomeController;
use Illuminate\Support\Facades\Route;
Route::get('/', HomeController::class);تا اینجا، اگه فایلهای پوسته starter رو ساخته باشید و دستور build هم با موفقیت اجرا شده باشه، باید بتونید صفحه اصلی سایت رو با ظاهر این پوسته ببینید.
حالا به بخش اصلی قابلیت چندپوستهای میرسیم. میخوایم مدیر سایت بتونه پوستههای موجود رو ببینه، اطلاعاتشون رو بررسی کنه و پوسته موردنظرش رو فعال کنه.
ابتدا یک Controller میسازیم:
php artisan make:controller Admin/ThemeControllerفایل app/Http/Controllers/Admin/ThemeController.php رو با کد زیر تکمیل میکنیم:
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Support\ThemeManager;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class ThemeController extends Controller
{
public function index(ThemeManager $themes): View
{
$items = collect($themes->all())
->map(function (array $theme, string $slug) use ($themes) {
$theme['_slug'] = $slug;
$theme['_active'] = $themes->activeSlug() === $slug;
$theme['_usable'] = $themes->isUsable($slug);
return $theme;
})
->values();
return view('admin.themes.index', [
'themes' => $items,
'activeTheme' => $themes->activeSlug(),
]);
}
public function activate(
Request $request,
string $slug,
ThemeManager $themes
): RedirectResponse {
$themes->activate($slug);
return redirect()
->route('admin.themes.index')
->with('success', 'Theme activated successfully.');
}
}کنترلر عمداً منطق پیدا کردن و اعتبارسنجی پوسته رو دوباره پیادهسازی نمیکنه؛ این مسئولیتها در ThemeManager قرار دارن. در نتیجه، اگه بعداً بخوایم روش بررسی پوستهها رو تغییر بدیم، لازم نیست چند بخش از برنامه رو اصلاح کنیم.
حالا مسیرهای مربوط به مدیریت پوستهها رو هم تو فایل routes/web.php اضافه میکنیم.
فعلاً این مسیرها رو بدون middleware خاصی تعریف میکنیم؛ شما میتونید با توجه به ساختار پنل مدیریت پروژهتون، احراز هویت و middlewareهای موردنیاز رو به اونها اضافه کنید:
use App\Http\Controllers\Admin\ThemeController;
Route::middleware(['auth'])
->prefix('admin')
->name('admin.')
->group(function () {
Route::get('/themes', [ThemeController::class, 'index'])
->name('themes.index');
Route::post('/themes/{slug}/activate', [
ThemeController::class,
'activate',
])->name('themes.activate');
});این نمونه از middleware احراز هویت auth استفاده میکنه. در سایت واقعی، باید دسترسی این مسیرها رو به مدیران مجاز محدود کنید؛
برای دیدن صفحه اگه auth رو هم هنوز اضافه نکردید این مورد رو فعلا حذف کنید :)
پوشه resources/views/admin/themes رو میسازیم و فایل index.blade.php رو داخلش قرار میدیم تا فهرست پوستههای موجود و امکان انتخاب پوسته فعال رو نمایش بدیم:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Themes</title>
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600&display=swap" rel="stylesheet">
<style>
body {
font-family: Geist, ui-sans-serif, system-ui, sans-serif;
}
</style>
</head>
<body class="bg-neutral-50 text-neutral-700 antialiased">
<div class="flex min-h-screen">
{{-- Sidebar --}}
<aside class="hidden w-60 shrink-0 flex-col border-r border-neutral-200 bg-white md:flex">
<div class="flex h-14 items-center border-b border-neutral-200 px-5 font-semibold text-black">
{{ config('app.name') }}
</div>
<nav class="flex-1 space-y-1 p-3 text-sm">
<a href="#" class="flex items-center rounded-md px-3 py-2 text-neutral-500 hover:bg-neutral-100 hover:text-black">
Dashboard
</a>
<a href="#" class="flex items-center rounded-md bg-neutral-100 px-3 py-2 font-medium text-black">
Themes
</a>
<a href="#" class="flex items-center rounded-md px-3 py-2 text-neutral-500 hover:bg-neutral-100 hover:text-black">
Settings
</a>
</nav>
<div class="border-t border-neutral-200 p-4 font-mono text-xs text-neutral-400">
Admin Panel
</div>
</aside>
{{-- Main --}}
<div class="flex min-w-0 flex-1 flex-col">
{{-- Topbar --}}
<header class="flex h-14 items-center justify-between border-b border-neutral-200 bg-white px-6">
<div class="text-sm text-neutral-400">
Admin <span class="mx-1">/</span> <span class="text-black">Themes</span>
</div>
<a href="/" target="_blank"
class="rounded-md border border-neutral-200 px-3 py-1.5 text-sm font-medium text-black hover:bg-neutral-50">
View site
</a>
</header>
<main class="flex-1 p-6 lg:p-8">
<div class="mx-auto max-w-5xl">
<div class="mb-8">
<h1 class="text-2xl font-semibold tracking-tight text-black">Themes</h1>
<p class="mt-1 text-sm text-neutral-500">
Manage the themes installed in your project.
</p>
</div>
@if (session('success'))
<div class="mb-6 rounded-md border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700">
{{ session('success') }}
</div>
@endif
@if ($errors->any())
<div class="mb-6 rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
@foreach ($errors->all() as $error)
<div>{{ $error }}</div>
@endforeach
</div>
@endif
<section class="grid gap-5 sm:grid-cols-2 xl:grid-cols-3">
@forelse ($themes as $theme)
<article class="flex flex-col overflow-hidden rounded-lg border bg-white {{ $theme['_active'] ? 'border-2 border-blue-500' : 'border-neutral-200' }}">
<div class="flex aspect-video items-center justify-center border-b border-neutral-200 bg-neutral-100 text-xs text-neutral-400">
@if (!empty($theme['_preview_url']))
<img src="{{ $theme['_preview_url'] }}"
alt="{{ $theme['name'] ?? $theme['_slug'] }}"
class="h-full w-full object-cover">
@else
No preview image
@endif
</div>
<div class="flex flex-1 flex-col p-5">
<div class="flex items-start justify-between gap-3">
<h2 class="font-medium text-black">{{ $theme['name'] ?? $theme['_slug'] }}</h2>
@if ($theme['_active'])
<span class="inline-flex items-center gap-1.5 rounded-full bg-emerald-50 px-2 py-0.5 text-xs font-medium text-emerald-700">
<span class="size-1.5 rounded-full bg-emerald-500"></span> Active
</span>
@elseif (!$theme['_usable'])
<span class="rounded-full bg-red-50 px-2 py-0.5 text-xs font-medium text-red-700">
Not ready
</span>
@else
<span class="rounded-full bg-neutral-100 px-2 py-0.5 text-xs font-medium text-neutral-600">
Installed
</span>
@endif
</div>
<p class="mt-2 text-sm text-neutral-500">
{{ $theme['description'] ?? 'No description provided.' }}
</p>
<div class="mt-4 font-mono text-xs text-neutral-400">
v{{ $theme['version'] ?? '?' }} · {{ $theme['author'] ?? 'Unknown' }}
</div>
<div class="mt-auto pt-5">
@if (!$theme['_active'])
<form method="POST" action="{{ route('admin.themes.activate', ['slug' => $theme['_slug']]) }}">
@csrf
<button type="submit" @disabled(!$theme['_usable'])
class="w-full rounded-md bg-black px-3 py-2 text-sm font-medium text-white hover:bg-neutral-800 disabled:cursor-not-allowed disabled:bg-neutral-200 disabled:text-neutral-400">
Activate
</button>
</form>
@if (!$theme['_usable'])
<p class="mt-2 text-xs text-red-600">
Check the home view and build files first.
</p>
@endif
@else
<div class="w-full rounded-md border border-neutral-200 bg-neutral-50 px-3 py-2 text-center text-sm text-neutral-400">
Currently in use
</div>
@endif
</div>
</div>
</article>
@empty
<p class="col-span-full rounded-lg border border-dashed border-neutral-300 p-10 text-center text-sm text-neutral-400">
No themes found.
</p>
@endforelse
</section>
</div>
</main>
</div>
</div>
</body>
</html>این صفحه پوستهها رو از اطلاعاتی که ThemeManager پیدا کرده نمایش میده. تصویر پیشنمایش، نام، توضیحات و نسخه از فایل theme.json میان. اگه پوسته آماده استفاده نباشه، دکمه فعالسازی اون غیرفعال میشه.
حالا میخوایم مطمئن بشیم سیستم واقعاً میتونه بین دو پوسته جابهجا بشه.
پوشه themes/minimal رو با ساختاری مشابه starter میسازیم. فقط کافیه اطلاعات فایل theme.json رو متناسب با پوسته minimal تغییر بدیم:
{
"name": "Minimal",
"slug": "minimal",
"description": "A minimal alternative theme.",
"version": "1.0.0",
"author": "SpaceDev",
"preview": "assets/images/cover.jpg",
"requires": {
"laravel": "^13.0"
},
"views": {
"home": "pages.home"
},
"assets": {
"css": "assets/css/app.css",
"js": "assets/js/app.js"
}
}فایل vite.config.js این پوسته باید مشابه تنظیمات پوسته قبلی باشه؛ فقط مسیرهای starter رو به minimal تغییر میدم:
import { cpSync, existsSync } from "node:fs";
import { resolve } from "node:path";
import { defineConfig } from "vite";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
root: resolve(import.meta.dirname),
base: "/themes/minimal/build/",
plugins: [
tailwindcss(),
{
name: "copy-theme-images",
closeBundle() {
const from = resolve(import.meta.dirname, "assets/images");
const to = resolve(import.meta.dirname, "../../public/themes/minimal/assets/images");
if (existsSync(from)) {
cpSync(from, to, { recursive: true });
}
},
},
],
build: {
outDir: resolve(
import.meta.dirname,
"../../public/themes/minimal/build"
),
emptyOutDir: true,
manifest: "manifest.json",
rollupOptions: {
input: {
"css/app": resolve(
import.meta.dirname,
"assets/css/app.css"
),
"js/app": resolve(
import.meta.dirname,
"assets/js/app.js"
),
},
},
},
});فایل cssش هم همون مشابه starter بمونه چون از تیلویند استفاده میکنیم ظاهر رو توی blade یکم متفاوت میزنیم :)
@import "tailwindcss" source(none);
@source "../../views/**/*.blade.php";تو فایل themes/minimal/assets/js/app.js هم میتونیم بنویسیم:
document.addEventListener('DOMContentLoaded', () => {
console.log('Minimal theme loaded.');
});حالا viewهای layouts/app.blade.php و pages/home.blade.php رو برای این پوسته ایجاد میکنیم. میتونیم ساختار اونها رو از پوسته اول کپی کنیم و ظاهرشون رو تغییر بدیم.
فایل layouts/app.blade.php:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ $title ?? 'Minimal Theme' }}</title>
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600&display=swap" rel="stylesheet">
<style>
body {
font-family: Geist, ui-sans-serif, system-ui, sans-serif;
}
</style>
{!! app(\App\Support\ThemeManager::class)->assetTags() !!}
</head>
<body class="bg-white text-neutral-600 antialiased">
<div class="flex flex-col min-h-screen">
<header>
<div class="w-full max-w-3xl px-6 mx-auto">
<div class="flex items-center justify-between h-16">
<div class="font-semibold text-black">
{{ config('app.name') }}
</div>
<nav class="flex gap-6 text-sm text-neutral-500">
<a href="#" class="hover:text-black">Docs</a>
<a href="#" class="hover:text-black">Blog</a>
<a href="#" class="hover:text-black">GitHub</a>
</nav>
</div>
</div>
</header>
<main class="flex-1">
{{ $slot }}
</main>
<footer class="mt-10 border-t border-neutral-200 py-8 text-center font-mono text-xs text-neutral-400">
© {{ date('Y') }} {{ config('app.name') }}
</footer>
</div>
</body>
</html>اینم فایل pages/home.blade.php:
<x-theme::layouts.app title="Minimal Theme">
<section>
<div class="w-full max-w-3xl px-6 mx-auto">
<div class="py-24">
<span class="font-mono text-xs uppercase tracking-widest text-neutral-400">
Theme / Minimal
</span>
<h1 class="mt-6 text-4xl font-semibold tracking-tight text-black sm:text-5xl">
Less noise.<br>
More Laravel.
</h1>
<p class="mt-6 max-w-lg leading-7 text-neutral-500">
This page is rendered by the active theme. Switch themes and only the views change.
</p>
<div class="mt-10 flex gap-3">
<a href="#"
class="rounded-md bg-black px-4 py-2 text-sm font-medium text-white hover:bg-neutral-800">Get
Started</a>
<a href="#"
class="rounded-md border border-neutral-200 px-4 py-2 text-sm font-medium text-black hover:bg-neutral-50">Documentation</a>
</div>
</div>
</div>
</section>
<section>
<div class="w-full max-w-3xl px-6 mx-auto">
<ul class="border-t border-neutral-200 divide-y divide-neutral-200">
@foreach ([
['Routing', 'Define routes once, serve them with any theme.'],
['Blade', 'Views are resolved from the active theme first.'],
['Eloquent', 'Your data layer never knows the theme exists.'],
] as [$title, $text])
<li class="flex flex-col gap-1 py-6 sm:flex-row sm:items-baseline sm:gap-10">
<h3 class="w-32 shrink-0 font-medium text-black">{{ $title }}</h3>
<p class="text-sm leading-6 text-neutral-500">{{ $text }}</p>
</li>
@endforeach
</ul>
</div>
</section>
</x-theme::layouts.app>حالا خروجی این پوسته رو هم میسازیم، دستور زیر رو از ریشه پروژه اجرا میکنیم:
npx vite build --config themes/minimal/vite.config.jsاگه همهچیز درست باشه، هر دو پوسته فایل manifest.json و فایلهای build خودشون رو خواهند داشت :)
قبل از اینکه امکانات بیشتری به سیستم اضافه کنیم، بهتره این سه مرحله رو انجام بدیم تا مطمئن بشیم ساختار فعلی درست کار میکنه:
۱.مطمئن بشید migration جدول settings اجرا شده.
۲.دستور build رو برای هر دو پوسته starter و minimal اجرا کنید تا فایلهای خروجی ساخته بشن.
۳.سرور لاراول رو اجرا کنید:
php artisan serve۴.به مسیر /admin/themes برید.
۵.باید دو پوسته Starter و Minimal رو در پنل مدیریت ببینید.
۶.روی دکمه «فعال کردن پوسته» برای Minimal کلیک کنید.
۷.به صفحه اصلی سایت برگردید. باید ظاهر صفحه تغییر کرده باشه و پیام Minimal theme loaded. هم در console مرورگر نمایش داده بشه.
۸.دوباره پوسته Starter رو فعال کنید و مطمئن بشید که سایت به ظاهر قبلی برمیگرده.
اگه پوستهای در فهرست دیده نمیشه، اول وجود theme.json و یکسان بودن نام پوشه با مقدار slug رو بررسی کنید. اگه پوسته در فهرست هست ولی امکان فعال کردنش رو ندارید، وجود view صفحه اصلی و فایل public/themes/{slug}/build/manifest.json رو بررسی کنید.
تا اینجا یک سیستم پایه داریم که پوستهها رو از فایلهای پروژه میخونه، پوسته فعال رو در دیتابیس ذخیره میکنه و فایلهای ظاهری همون پوسته رو بارگذاری میکنه. برای استفاده از این ساختار در یک پروژه بزرگتر، چند نکته رو باید در نظر بگیریم.
تو فایل theme.json اطلاعاتی مثل نسخه و سازگاری رو تعریف کردیم، اما در این نسخه هنوز بررسی خودکار نسخه لاراول انجام نمیشه. اگه قراره پوستهها از منابع مختلف نصب بشن، باید اعتبارسنجی نسخه، ساختار فایلها و قراردادهای موردنیاز هم به سیستم اضافه بشه.
در این پیادهسازی، نصب پوسته به معنی قرار دادن پوشه اون در مسیر themes و آماده کردن فایلهای build هست.
پنل مدیریت هم فایلهای پوستهها رو حذف یا نصب نمیکنه؛ فقط مشخص میکنه کدوم پوسته برای نمایش سایت استفاده بشه.
اگه بخوایم امکان بارگذاری فایل zip و نصب پوسته از پنل رو هم داشته باشیم، باید فایلها رو با دقت اعتبارسنجی کنیم، از مسیرهای ناامن جلوگیری کنیم و فرایند استخراج و build رو جداگانه مدیریت کنیم. اجرای فایلهای ناشناخته از داخل یک درخواست وب کار امنی نیست :)
تو این ساختار، پوسته مسئول ظاهر سایت، viewها و فایلهای css و js هست. اگه هر پوسته قرار باشه controller یا route یا request یا منطق php مستقل خودش رو هم داشته باشه، در عمل داریم به سمت معماری افزونه یا ماژول حرکت میکنیم.
چنین سیستمی به قرارداد مشخص، مدیریت وابستگیها، کنترل دسترسی و بررسی سازگاری نیاز داره. برای همین، بهتره منطق اصلی برنامه رو در خود لاراول نگه داریم و پوستهها رو تا حد ممکن به لایه نمایش محدود کنیم.
برای ساده موندن مثال، تو این نسخه پوستهها از پوشههای پروژه خونده میشن و بررسی فایلها هنگام اجرا انجام میشه. در یک پروژه بزرگتر میتونیم فهرست پوستهها و اطلاعات manifest رو cache کنیم و هنگام نصب یا بهروزرسانی پوسته، cache مربوطه رو پاک کنیم.
همچنین بهتره پیش از انتشار نسخه جدید یک پوسته، فایلهای build و viewهای موردنیازش بررسی بشن. اگر پوسته فعال بهطور ناگهانی حذف بشه یا فایلهای خروجی اون از بین برن، سیستم میتونه به پوسته پیشفرض یا صفحه جایگزین برگرده.
برای ساخت سیستم چندپوستهای در لاراول، لازم نیست کل برنامه رو برای هر ظاهر دوباره پیادهسازی کنیم. میتونیم ساختار مشخصی برای پوستهها داشته باشیم، اطلاعات هر پوسته رو در theme.json نگه داریم، پوسته فعال رو در دیتابیس ذخیره کنیم و با یک هسته اصلی مثل ThemeManager، انتخاب view و فایلهای ظاهری رو مدیریت کنیم.
تو این پروژه، یک پنل ساده هم ساختیم تا مدیر سایت بتونه پوستههای موجود رو ببینه و بین اونها جابهجا بشه. این ساختار نقطه شروع مناسبی برای توسعه امکانات بیشتر مثل نصب پوسته، پیشنمایش قبل از فعالسازی و مدیریت نسخههاست؛ بدون اینکه از همون ابتدا منطق اصلی برنامه رو با کدهای ظاهری درهم ترکیب کنیم.
پیشنهاد میکنم یه پروژه جدید لاراول بسازید و قدمبهقدم با مقاله پیش برید. کدهایی هم که براتون قرار دادم، مخصوصاً کدهای مربوط به ظاهر صفحات رو بررسی کنید که اتفاقاً بیشتر از نوشتن خود مقاله ازم وقت گرفتن :))
در نهایت خروجی رو ببینید و اگه سؤال یا نظری داشتید، توی بخش نظرات برام بنویسید :)
فعلاً تا مقاله بعدی، بدرود 👋
دیدگاه و یا پرسش خود را برای ما ارسال کنید.
هنوز دیدگاه یا پرسشی ایجاد نشده است :/
تجربهها، دیدگاهها و نکات الهامبخشی که با شما به اشتراک میگذاریم.