feat: VivesPOS landing on Winter CMS 1.2 — theme + plugin + Dockerfile
Some checks are pending
Module sub-split / Sub-split (push) Waiting to run
Some checks are pending
Module sub-split / Sub-split (push) Waiting to run
- Base: wintercms/winter branch 1.2 (full framework) - Theme vivespos: Canvas 7 + Bootstrap 5 CDN, custom CSS - Layout: deferred GTM/GA4 tracking, JSON-LD SoftwareApplication - Partials: hero (offline-first), features, modes (offline/nube toggle), screenshots, pricing (3 planes), comparison, FAQ, CTA - Plugin VivesPOS.Site with ContactForm - Dockerfile: PHP 8.2 Apache, port 80, healthcheck - Added winter/wn-pages, blog, sitemap, seo plugins - Active theme set to vivespos
This commit is contained in:
22
modules/cms/LICENSE
Normal file
22
modules/cms/LICENSE
Normal file
@@ -0,0 +1,22 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2013-2021.03.01 October CMS
|
||||
Copyright (c) 2021 Winter CMS
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
5
modules/cms/README.md
Normal file
5
modules/cms/README.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# Winter CMS - CMS Module
|
||||
|
||||
This repository is a read-only sub-split of the Winter CMS `Cms` module for use in Composer. Please note that we do not accept any pull requests to this repository.
|
||||
|
||||
If you wish to make changes to this module, please submit them to the [main repository](https://github.com/wintercms/winter).
|
||||
493
modules/cms/ServiceProvider.php
Normal file
493
modules/cms/ServiceProvider.php
Normal file
@@ -0,0 +1,493 @@
|
||||
<?php
|
||||
|
||||
namespace Cms;
|
||||
|
||||
use Backend;
|
||||
use Backend\Classes\WidgetManager;
|
||||
use Backend\Facades\BackendAuth;
|
||||
use Backend\Facades\BackendMenu;
|
||||
use Backend\Models\UserRole;
|
||||
use Cms\Classes\CmsController;
|
||||
use Cms\Classes\CmsObject;
|
||||
use Cms\Classes\ComponentManager;
|
||||
use Cms\Classes\Page as CmsPage;
|
||||
use Cms\Classes\Router;
|
||||
use Cms\Classes\Theme;
|
||||
use Cms\Models\ThemeData;
|
||||
use Cms\Models\ThemeLog;
|
||||
use Cms\Twig\DebugExtension;
|
||||
use Cms\Twig\Extension as CmsTwigExtension;
|
||||
use Cms\Twig\Loader as CmsTwigLoader;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Response;
|
||||
use Illuminate\Support\Facades\View;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
|
||||
use System\Classes\CombineAssets;
|
||||
use System\Classes\MarkupManager;
|
||||
use System\Classes\SettingsManager;
|
||||
use Twig\Cache\FilesystemCache as TwigCacheFilesystem;
|
||||
use Winter\Storm\Support\Facades\Event;
|
||||
use Winter\Storm\Support\Facades\Url;
|
||||
use Winter\Storm\Support\ModuleServiceProvider;
|
||||
|
||||
class ServiceProvider extends ModuleServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register the service provider.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function register()
|
||||
{
|
||||
parent::register();
|
||||
|
||||
$this->registerConsole();
|
||||
$this->registerErrorHandler();
|
||||
$this->registerTwigParser();
|
||||
$this->registerComponents();
|
||||
$this->registerThemeLogging();
|
||||
$this->registerCombinerEvents();
|
||||
$this->registerHalcyonModels();
|
||||
$this->registerBackendPermissions();
|
||||
|
||||
/*
|
||||
* Backend specific
|
||||
*/
|
||||
if ($this->app->runningInBackend()) {
|
||||
$this->registerBackendNavigation();
|
||||
$this->registerBackendReportWidgets();
|
||||
$this->registerBackendWidgets();
|
||||
$this->registerBackendSettings();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap the module events.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function boot()
|
||||
{
|
||||
$this->registerAssetBundles();
|
||||
|
||||
parent::boot('cms');
|
||||
|
||||
$this->bootMenuItemEvents();
|
||||
$this->bootRichEditorEvents();
|
||||
}
|
||||
|
||||
/**
|
||||
* Register command line specifics
|
||||
*/
|
||||
protected function registerConsole()
|
||||
{
|
||||
$this->registerConsoleCommand('create.component', \Cms\Console\CreateComponent::class);
|
||||
$this->registerConsoleCommand('create.theme', \Cms\Console\CreateTheme::class);
|
||||
|
||||
$this->registerConsoleCommand('theme.install', \Cms\Console\ThemeInstall::class);
|
||||
$this->registerConsoleCommand('theme.remove', \Cms\Console\ThemeRemove::class);
|
||||
$this->registerConsoleCommand('theme.list', \Cms\Console\ThemeList::class);
|
||||
$this->registerConsoleCommand('theme.use', \Cms\Console\ThemeUse::class);
|
||||
$this->registerConsoleCommand('theme.sync', \Cms\Console\ThemeSync::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Error handling for abort() errors
|
||||
*/
|
||||
protected function registerErrorHandler()
|
||||
{
|
||||
$this->app->error(function (HttpExceptionInterface $exception, $code, $fromConsole) {
|
||||
if ($this->app->runningInBackend() && BackendAuth::check()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$theme = Theme::getActiveTheme();
|
||||
$controller = new CmsController($theme);
|
||||
if ($code === 404) {
|
||||
return Response::make($controller->run('/404')->original, 404, []);
|
||||
}
|
||||
|
||||
if (!Config::get('app.debug', false)) {
|
||||
$router = new Router($theme);
|
||||
// Use the default view if no "/error" URL is found.
|
||||
if (!$router->findByUrl('/error')) {
|
||||
$result = View::make('cms::error');
|
||||
} else {
|
||||
// Route to the CMS error page.
|
||||
$controller = new CmsController($theme);
|
||||
$result = $controller->run('/error')->original;
|
||||
}
|
||||
|
||||
return Response::make($result, $code, []);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
* Register Twig Environments and other Twig modifications provided by the module
|
||||
*/
|
||||
protected function registerTwigParser()
|
||||
{
|
||||
// Register CMS Twig environment
|
||||
$this->app->bind('twig.environment.cms', function ($app) {
|
||||
// Load Twig options
|
||||
$useCache = !Config::get('cms.twigNoCache');
|
||||
$isDebugMode = Config::get('app.debug', false);
|
||||
$strictVariables = Config::get('cms.enableTwigStrictVariables', false);
|
||||
$strictVariables = $strictVariables ?? $isDebugMode;
|
||||
$forceBytecode = Config::get('cms.forceBytecodeInvalidation', false);
|
||||
|
||||
$options = [
|
||||
'auto_reload' => true,
|
||||
'debug' => $isDebugMode,
|
||||
'strict_variables' => $strictVariables,
|
||||
];
|
||||
|
||||
if ($useCache) {
|
||||
$theme = Theme::getActiveTheme();
|
||||
$themeDir = $theme->getDirName();
|
||||
if ($parent = $theme->getConfig()['parent'] ?? false) {
|
||||
$themeDir .= '-' . $parent;
|
||||
}
|
||||
|
||||
$options['cache'] = new TwigCacheFilesystem(
|
||||
storage_path(implode(DIRECTORY_SEPARATOR, [
|
||||
'cms',
|
||||
'twig',
|
||||
$themeDir,
|
||||
])) . DIRECTORY_SEPARATOR,
|
||||
$forceBytecode ? TwigCacheFilesystem::FORCE_BYTECODE_INVALIDATION : 0
|
||||
);
|
||||
}
|
||||
|
||||
$twig = MarkupManager::makeBaseTwigEnvironment(new CmsTwigLoader, $options);
|
||||
$twig->addExtension(new CmsTwigExtension);
|
||||
if ($isDebugMode) {
|
||||
$twig->addExtension(new DebugExtension);
|
||||
}
|
||||
|
||||
return $twig;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register asset bundles
|
||||
*/
|
||||
protected function registerAssetBundles()
|
||||
{
|
||||
CombineAssets::registerCallback(function ($combiner) {
|
||||
$combiner->registerBundle('~/modules/cms/assets/less/winter.components.less');
|
||||
$combiner->registerBundle('~/modules/cms/assets/less/winter.theme-selector.less');
|
||||
$combiner->registerBundle('~/modules/cms/widgets/assetlist/assets/less/assetlist.less');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register components.
|
||||
*/
|
||||
protected function registerComponents()
|
||||
{
|
||||
ComponentManager::instance()->registerComponents(function ($manager) {
|
||||
$manager->registerComponent(\Cms\Components\ViewBag::class, 'viewBag');
|
||||
$manager->registerComponent(\Cms\Components\Resources::class, 'resources');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers theme logging on templates.
|
||||
*/
|
||||
protected function registerThemeLogging()
|
||||
{
|
||||
CmsObject::extend(function ($model) {
|
||||
ThemeLog::bindEventsToModel($model);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers events for the asset combiner.
|
||||
*/
|
||||
protected function registerCombinerEvents()
|
||||
{
|
||||
if ($this->app->runningInBackend() || $this->app->runningInConsole()) {
|
||||
return;
|
||||
}
|
||||
|
||||
Event::listen('cms.combiner.beforePrepare', function ($combiner, $assets) {
|
||||
$filters = array_flatten($combiner->getFilters());
|
||||
ThemeData::applyAssetVariablesToCombinerFilters($filters);
|
||||
});
|
||||
|
||||
Event::listen('cms.combiner.getCacheKey', function ($combiner, $holder) {
|
||||
$holder->key = $holder->key . ThemeData::getCombinerCacheKey();
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
* Register navigation
|
||||
*/
|
||||
protected function registerBackendNavigation()
|
||||
{
|
||||
BackendMenu::registerCallback(function ($manager) {
|
||||
$manager->registerMenuItems('Winter.Cms', [
|
||||
'cms' => [
|
||||
'label' => 'cms::lang.cms.menu_label',
|
||||
'icon' => 'icon-magic',
|
||||
'iconSvg' => 'modules/cms/assets/images/cms-icon.svg',
|
||||
'url' => Backend::url('cms'),
|
||||
'order' => 100,
|
||||
'permissions' => [
|
||||
'cms.manage_content',
|
||||
'cms.manage_assets',
|
||||
'cms.manage_pages',
|
||||
'cms.manage_layouts',
|
||||
'cms.manage_partials'
|
||||
],
|
||||
'sideMenu' => [
|
||||
'pages' => [
|
||||
'label' => 'cms::lang.page.menu_label',
|
||||
'icon' => 'icon-copy',
|
||||
'url' => 'javascript:;',
|
||||
'attributes' => ['data-menu-item' => 'pages'],
|
||||
'permissions' => ['cms.manage_pages'],
|
||||
'counterLabel' => 'cms::lang.page.unsaved_label'
|
||||
],
|
||||
'partials' => [
|
||||
'label' => 'cms::lang.partial.menu_label',
|
||||
'icon' => 'icon-tags',
|
||||
'url' => 'javascript:;',
|
||||
'attributes' => ['data-menu-item' => 'partials'],
|
||||
'permissions' => ['cms.manage_partials'],
|
||||
'counterLabel' => 'cms::lang.partial.unsaved_label'
|
||||
],
|
||||
'layouts' => [
|
||||
'label' => 'cms::lang.layout.menu_label',
|
||||
'icon' => 'icon-th-large',
|
||||
'url' => 'javascript:;',
|
||||
'attributes' => ['data-menu-item' => 'layouts'],
|
||||
'permissions' => ['cms.manage_layouts'],
|
||||
'counterLabel' => 'cms::lang.layout.unsaved_label'
|
||||
],
|
||||
'content' => [
|
||||
'label' => 'cms::lang.content.menu_label',
|
||||
'icon' => 'icon-file-text-o',
|
||||
'url' => 'javascript:;',
|
||||
'attributes' => ['data-menu-item' => 'content'],
|
||||
'permissions' => ['cms.manage_content'],
|
||||
'counterLabel' => 'cms::lang.content.unsaved_label'
|
||||
],
|
||||
'assets' => [
|
||||
'label' => 'cms::lang.asset.menu_label',
|
||||
'icon' => 'icon-picture-o',
|
||||
'url' => 'javascript:;',
|
||||
'attributes' => ['data-menu-item' => 'assets'],
|
||||
'permissions' => ['cms.manage_assets'],
|
||||
'counterLabel' => 'cms::lang.asset.unsaved_label'
|
||||
],
|
||||
'components' => [
|
||||
'label' => 'cms::lang.component.menu_label',
|
||||
'icon' => 'icon-puzzle-piece',
|
||||
'url' => 'javascript:;',
|
||||
'attributes' => ['data-menu-item' => 'components'],
|
||||
'permissions' => ['cms.manage_pages', 'cms.manage_layouts', 'cms.manage_partials']
|
||||
]
|
||||
]
|
||||
]
|
||||
]);
|
||||
$manager->registerQuickActions('Winter.Cms', [
|
||||
'preview' => [
|
||||
'label' => 'backend::lang.tooltips.preview_website',
|
||||
'icon' => 'icon-crosshairs',
|
||||
'url' => Url::to('/'),
|
||||
'order' => 10,
|
||||
'attributes' => [
|
||||
'target' => '_blank',
|
||||
'rel' => 'noopener noreferrer',
|
||||
],
|
||||
],
|
||||
]);
|
||||
$manager->registerOwnerAlias('Winter.Cms', 'October.Cms');
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
* Register report widgets
|
||||
*/
|
||||
protected function registerBackendReportWidgets()
|
||||
{
|
||||
WidgetManager::instance()->registerReportWidgets(function ($manager) {
|
||||
$manager->registerReportWidget(\Cms\ReportWidgets\ActiveTheme::class, [
|
||||
'label' => 'cms::lang.dashboard.active_theme.widget_title_default',
|
||||
'context' => 'dashboard'
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
* Register permissions
|
||||
*/
|
||||
protected function registerBackendPermissions()
|
||||
{
|
||||
BackendAuth::registerCallback(function ($manager) {
|
||||
$manager->registerPermissions('Winter.Cms', [
|
||||
'cms.manage_content' => [
|
||||
'label' => 'cms::lang.permissions.manage_content',
|
||||
'tab' => 'cms::lang.permissions.name',
|
||||
'comment' => 'cms::lang.permissions.manage_content_comment',
|
||||
'roles' => [UserRole::CODE_DEVELOPER],
|
||||
'order' => 100
|
||||
],
|
||||
'cms.manage_assets' => [
|
||||
'label' => 'cms::lang.permissions.manage_assets',
|
||||
'tab' => 'cms::lang.permissions.name',
|
||||
'comment' => 'cms::lang.permissions.manage_assets_comment',
|
||||
'roles' => [UserRole::CODE_DEVELOPER],
|
||||
'order' => 100
|
||||
],
|
||||
'cms.manage_pages' => [
|
||||
'label' => 'cms::lang.permissions.manage_pages',
|
||||
'tab' => 'cms::lang.permissions.name',
|
||||
'comment' => 'cms::lang.permissions.manage_pages_comment',
|
||||
'roles' => [UserRole::CODE_DEVELOPER],
|
||||
'order' => 100
|
||||
],
|
||||
'cms.manage_layouts' => [
|
||||
'label' => 'cms::lang.permissions.manage_layouts',
|
||||
'tab' => 'cms::lang.permissions.name',
|
||||
'comment' => 'cms::lang.permissions.manage_layouts_comment',
|
||||
'roles' => [UserRole::CODE_DEVELOPER],
|
||||
'order' => 100
|
||||
],
|
||||
'cms.manage_partials' => [
|
||||
'label' => 'cms::lang.permissions.manage_partials',
|
||||
'tab' => 'cms::lang.permissions.name',
|
||||
'comment' => 'cms::lang.permissions.manage_partials_comment',
|
||||
'roles' => [UserRole::CODE_DEVELOPER],
|
||||
'order' => 100
|
||||
],
|
||||
'cms.manage_themes' => [
|
||||
'label' => 'cms::lang.permissions.manage_themes',
|
||||
'tab' => 'cms::lang.permissions.name',
|
||||
'comment' => 'cms::lang.permissions.manage_themes_comment',
|
||||
'roles' => [UserRole::CODE_DEVELOPER],
|
||||
'order' => 100
|
||||
],
|
||||
'cms.manage_theme_options' => [
|
||||
'label' => 'cms::lang.permissions.manage_theme_options',
|
||||
'tab' => 'cms::lang.permissions.name',
|
||||
'roles' => [UserRole::CODE_DEVELOPER, UserRole::CODE_PUBLISHER],
|
||||
'order' => 100
|
||||
],
|
||||
]);
|
||||
$manager->registerPermissionOwnerAlias('Winter.Cms', 'October.Cms');
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
* Register widgets
|
||||
*/
|
||||
protected function registerBackendWidgets()
|
||||
{
|
||||
WidgetManager::instance()->registerFormWidgets(function ($manager) {
|
||||
$manager->registerFormWidget(FormWidgets\Components::class);
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
* Register settings
|
||||
*/
|
||||
protected function registerBackendSettings()
|
||||
{
|
||||
SettingsManager::instance()->registerCallback(function ($manager) {
|
||||
$manager->registerSettingItems('Winter.Cms', [
|
||||
'theme' => [
|
||||
'label' => 'cms::lang.theme.settings_menu',
|
||||
'description' => 'cms::lang.theme.settings_menu_description',
|
||||
'category' => SettingsManager::CATEGORY_CMS,
|
||||
'icon' => 'icon-picture-o',
|
||||
'url' => Backend::url('cms/themes'),
|
||||
'permissions' => ['cms.manage_themes', 'cms.manage_theme_options'],
|
||||
'order' => 200
|
||||
],
|
||||
'maintenance_settings' => [
|
||||
'label' => 'cms::lang.maintenance.settings_menu',
|
||||
'description' => 'cms::lang.maintenance.settings_menu_description',
|
||||
'category' => SettingsManager::CATEGORY_CMS,
|
||||
'icon' => 'icon-plug',
|
||||
'class' => Models\MaintenanceSetting::class,
|
||||
'permissions' => ['cms.manage_themes'],
|
||||
'order' => 300
|
||||
],
|
||||
'theme_logs' => [
|
||||
'label' => 'cms::lang.theme_log.menu_label',
|
||||
'description' => 'cms::lang.theme_log.menu_description',
|
||||
'category' => SettingsManager::CATEGORY_LOGS,
|
||||
'icon' => 'icon-magic',
|
||||
'url' => Backend::url('cms/themelogs'),
|
||||
'permissions' => ['system.access_logs'],
|
||||
'order' => 910,
|
||||
'keywords' => 'theme change log'
|
||||
]
|
||||
]);
|
||||
$manager->registerOwnerAlias('Winter.Cms', 'October.Cms');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers events for menu items.
|
||||
*/
|
||||
protected function bootMenuItemEvents()
|
||||
{
|
||||
Event::listen('pages.menuitem.listTypes', function () {
|
||||
return [
|
||||
'cms-page' => 'cms::lang.page.cms_page'
|
||||
];
|
||||
});
|
||||
|
||||
Event::listen('pages.menuitem.getTypeInfo', function ($type) {
|
||||
if ($type === 'cms-page') {
|
||||
return CmsPage::getMenuTypeInfo($type);
|
||||
}
|
||||
});
|
||||
|
||||
Event::listen('pages.menuitem.resolveItem', function ($type, $item, $url, $theme) {
|
||||
if ($type === 'cms-page') {
|
||||
return CmsPage::resolveMenuItem($item, $url, $theme);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers events for rich editor page links.
|
||||
*/
|
||||
protected function bootRichEditorEvents()
|
||||
{
|
||||
Event::listen('backend.richeditor.listTypes', function () {
|
||||
return [
|
||||
'cms-page' => 'cms::lang.page.cms_page'
|
||||
];
|
||||
});
|
||||
|
||||
Event::listen('backend.richeditor.getTypeInfo', function ($type) {
|
||||
if ($type === 'cms-page') {
|
||||
return CmsPage::getRichEditorTypeInfo($type);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers the models to be made available to the theme database layer
|
||||
*/
|
||||
protected function registerHalcyonModels()
|
||||
{
|
||||
Event::listen('system.console.theme.sync.getAvailableModelClasses', function () {
|
||||
return [
|
||||
Classes\Theme::class,
|
||||
Classes\Meta::class,
|
||||
Classes\Page::class,
|
||||
Classes\Layout::class,
|
||||
Classes\Content::class,
|
||||
Classes\Partial::class
|
||||
];
|
||||
});
|
||||
}
|
||||
}
|
||||
10
modules/cms/assets/css/themelogs/template-diff.css
Normal file
10
modules/cms/assets/css/themelogs/template-diff.css
Normal file
@@ -0,0 +1,10 @@
|
||||
del {
|
||||
text-decoration: none;
|
||||
color: #b30000;
|
||||
background: #fadad7;
|
||||
}
|
||||
ins {
|
||||
background: #eaf2c2;
|
||||
color: #406619;
|
||||
text-decoration: none;
|
||||
}
|
||||
104
modules/cms/assets/css/winter.components.css
Normal file
104
modules/cms/assets/css/winter.components.css
Normal file
@@ -0,0 +1,104 @@
|
||||
.draggable-component-item,
|
||||
.component-list .components div.layout-cell,
|
||||
div.control-componentlist div.components div.layout-cell{font-size:11px;cursor:pointer;background:#fff;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}
|
||||
.draggable-component-item:hover,
|
||||
.component-list .components div.layout-cell:hover,
|
||||
div.control-componentlist div.components div.layout-cell:hover{background:#6cc551}
|
||||
.draggable-component-item>div,
|
||||
.component-list .components div.layout-cell>div,
|
||||
div.control-componentlist div.components div.layout-cell>div{white-space:normal;color:#475354;position:relative;border-right:1px solid #ECF0F1}
|
||||
.draggable-component-item>div:before,
|
||||
.component-list .components div.layout-cell>div:before,
|
||||
div.control-componentlist div.components div.layout-cell>div:before{position:absolute;font-size:16px;left:15px;top:7px;opacity:0.7;filter:alpha(opacity=70)}
|
||||
.draggable-component-item>div:hover,
|
||||
.component-list .components div.layout-cell>div:hover,
|
||||
div.control-componentlist div.components div.layout-cell>div:hover{color:#fff}
|
||||
.draggable-component-item>div:hover:before,
|
||||
.component-list .components div.layout-cell>div:hover:before,
|
||||
div.control-componentlist div.components div.layout-cell>div:hover:before{opacity:1;filter:alpha(opacity=100)}
|
||||
.draggable-component-item>div:after,
|
||||
.component-list .components div.layout-cell>div:after,
|
||||
div.control-componentlist div.components div.layout-cell>div:after{position:absolute;font-size:37px;top:1px;z-index:50;color:#fff;text-shadow:0 0 1px #475354;width:12px;overflow:hidden;text-indent:-25px;right:-12px;font-family:"Font Awesome 6 Free";font-weight:900;-moz-osx-font-smoothing:grayscale;-webkit-font-smoothing:antialiased;font-style:normal;font-variant:normal;text-rendering:auto;content:"\f12e"}
|
||||
.draggable-component-item>div:hover:after,
|
||||
.component-list .components div.layout-cell>div:hover:after,
|
||||
div.control-componentlist div.components div.layout-cell>div:hover:after{text-shadow:none;color:#6cc551}
|
||||
.draggable-component-item>div span,
|
||||
.component-list .components div.layout-cell>div span,
|
||||
div.control-componentlist div.components div.layout-cell>div span{display:block}
|
||||
.draggable-component-item>div span.name,
|
||||
.component-list .components div.layout-cell>div span.name,
|
||||
div.control-componentlist div.components div.layout-cell>div span.name{white-space:nowrap;padding:8px 15px 0;font-weight:400;line-height:20px;font-size:13px}
|
||||
.draggable-component-item>div span.description,
|
||||
.component-list .components div.layout-cell>div span.description,
|
||||
div.control-componentlist div.components div.layout-cell>div span.description{padding:0 15px 10px;margin-top:8px;font-weight:400;font-size:11px;line-height:150%}
|
||||
.draggable-component-item.placeholder>div,
|
||||
.component-list .components div.layout-cell.placeholder>div,
|
||||
div.control-componentlist div.components div.layout-cell.placeholder>div{background:#e0e0e0;color:#e0e0e0}
|
||||
.draggable-component-item.placeholder>div:before,
|
||||
.component-list .components div.layout-cell.placeholder>div:before,
|
||||
div.control-componentlist div.components div.layout-cell.placeholder>div:before,
|
||||
.draggable-component-item.placeholder>div:after,
|
||||
.component-list .components div.layout-cell.placeholder>div:after,
|
||||
div.control-componentlist div.components div.layout-cell.placeholder>div:after{color:#e0e0e0 !important;text-shadow:none !important}
|
||||
[data-field-name="components"] + .control-tabs.primary-tabs{margin-top:-10px}
|
||||
div.control-componentlist{position:relative;padding:0;-webkit-transition:all 0.3s ease;transition:all 0.3s ease}
|
||||
div.control-componentlist.droppable{background-color:#79cbe1}
|
||||
div.control-componentlist.has-components{padding:0 20px 20px}
|
||||
div.control-componentlist div.layout{width:auto}
|
||||
div.control-componentlist div.components div.layout-cell.error-component{background:#e01346}
|
||||
div.control-componentlist div.components div.layout-cell.error-component>div{color:#fff}
|
||||
div.control-componentlist div.components div.layout-cell.error-component>div:after{color:#e01346}
|
||||
div.control-componentlist div.components div.layout-cell.warning-component{background:#ffc107}
|
||||
div.control-componentlist div.components div.layout-cell.warning-component>div{color:#343a40}
|
||||
div.control-componentlist div.components div.layout-cell.warning-component>div:after{color:#ffc107}
|
||||
div.control-componentlist div.components div.layout-cell:first-child{border-bottom-left-radius:3px;border-top-left-radius:3px}
|
||||
div.control-componentlist div.components div.layout-cell:last-child{margin-right:0;border-bottom-right-radius:3px;border-top-right-radius:3px}
|
||||
div.control-componentlist div.components div.layout-cell:last-child>div{border-right:none}
|
||||
div.control-componentlist div.components div.layout-cell:last-child>div:after{display:none}
|
||||
div.control-componentlist div.components div.layout-cell:nth-child(2n)>div:after{top:auto;bottom:5px}
|
||||
div.control-componentlist div.components div.layout-cell:first-child>div.popover-highlight{border-bottom-left-radius:3px;border-top-left-radius:3px}
|
||||
div.control-componentlist div.components div.layout-cell:last-child>div.popover-highlight{border-bottom-right-radius:3px;border-top-right-radius:3px}
|
||||
div.control-componentlist div.components div.layout-cell>div{-webkit-transform:translate(0,0);-ms-transform:translate(0,0);transform:translate(0,0);-webkit-transition:-webkit-transform 0.2s;-moz-transition:-moz-transform 0.2s;-o-transition:-o-transform 0.2s;transition:transform 0.2s;max-width:250px;min-width:170px}
|
||||
div.control-componentlist div.components div.layout-cell>div.popover-highlight{border-right-color:rgba(0,0,0,0);background:#fff !important;color:#475354 !important}
|
||||
div.control-componentlist div.components div.layout-cell>div.popover-highlight:before{opacity:0.7;filter:alpha(opacity=70)}
|
||||
div.control-componentlist div.components div.layout-cell>div.popover-highlight:after{color:#fff;text-shadow:none}
|
||||
div.control-componentlist div.components div.layout-cell>div.popover-highlight a.remove{display:none}
|
||||
div.control-componentlist div.components div.layout-cell>div span.name{padding-left:38px}
|
||||
div.control-componentlist div.components div.layout-cell>div span.description{padding-bottom:35px}
|
||||
div.control-componentlist div.components div.layout-cell>div span.alias{padding:0 15px;font-weight:500;position:absolute;width:100%;bottom:10px;white-space:nowrap;text-overflow:ellipsis;overflow:hidden}
|
||||
div.control-componentlist div.components div.layout-cell>div span.alias:before{margin-right:4px;opacity:0.55;filter:alpha(opacity=55)}
|
||||
div.control-componentlist div.components div.layout-cell>div a.remove{position:absolute;display:inline-block;top:1px;right:5px;color:#000;font-size:17px;font-weight:bold;opacity:0.3;filter:alpha(opacity=30)}
|
||||
div.control-componentlist div.components div.layout-cell>div a.remove:hover{opacity:0.5;filter:alpha(opacity=50);text-decoration:none}
|
||||
div.control-componentlist div.components div.layout-cell.adding>div{-webkit-transform:translate(0,-100px) !important;-ms-transform:translate(0,-100px) !important;transform:translate(0,-100px) !important}
|
||||
.draggable-component-item{opacity:0.6;filter:alpha(opacity=60)}
|
||||
.draggable-component-item span.alias{display:none}
|
||||
.draggable-component-item a.remove{display:none}
|
||||
.component-list .components div.layout div.layout-row div.layout-cell{border-top:1px solid #ECF0F1}
|
||||
.component-list .components div.layout div.layout-row div.layout-cell span.alias{display:none}
|
||||
.component-list .components div.layout div.layout-row div.layout-cell a.remove{display:none}
|
||||
.component-list .components div.layout div.layout-row div.layout-cell:last-child>div{border-right:none}
|
||||
.component-list .components div.layout div.layout-row div.layout-cell:last-child>div:after{display:none}
|
||||
.component-list .components div.layout div.layout-row div.layout-cell>div:before{position:absolute;font-size:37px;top:1px;z-index:50;color:#fff;text-shadow:0 0 1px #475354;width:12px;overflow:hidden;text-indent:-25px;right:-12px;font-family:"Font Awesome 6 Free";font-weight:900;-moz-osx-font-smoothing:grayscale;-webkit-font-smoothing:antialiased;font-style:normal;font-variant:normal;text-rendering:auto;content:"\f12e";-webkit-transform:rotate(-90deg);-ms-transform:rotate(-90deg);transform:rotate(-90deg);opacity:1;filter:alpha(opacity=100);left:auto;top:-17px;right:15px}
|
||||
.component-list .components div.layout div.layout-row div.layout-cell>div:hover:before{text-shadow:none;color:#6cc551}
|
||||
.component-list .components div.layout div.layout-row:first-child div.layout-cell>div:before{display:none}
|
||||
.component-list .components div.layout.single div.layout-row>div.layout-cell>div{border-right:none}
|
||||
.component-list .components div.layout.single div.layout-row>div.layout-cell>div:before{display:block;right:55.5%}
|
||||
.control-filelist.component-list ul li div.group{background:#f1f3f4;border-top:1px solid #e6ebed;padding:10px 15px 10px 10px;position:relative;cursor:pointer}
|
||||
.control-filelist.component-list ul li div.group h4{text-transform:uppercase;font-size:14px;margin-top:3px}
|
||||
.control-filelist.component-list ul li div.group h4 a{padding-left:33px}
|
||||
.control-filelist.component-list ul li div.group h4 a:before{left:0}
|
||||
.control-filelist.component-list ul li div.group h4 a:after{display:none}
|
||||
.control-filelist.component-list ul li div.group span.description{display:block;font-size:13px;padding-left:33px;color:#8f8f8f}
|
||||
.control-filelist.component-list ul li div.group i{position:absolute;left:22px;top:21px;font-size:16px;opacity:0.7;filter:alpha(opacity=70);color:#405261}
|
||||
.touch div.control-componentlist div.components div.layout-cell>div:hover,
|
||||
.touch div.control-componentlist div.components div.layout-cell>div:active,
|
||||
.touch div.control-componentlist div.components div.layout-cell>div:active:focus{background:#fff;color:#475354}
|
||||
.touch div.control-componentlist div.components div.layout-cell>div:hover:after,
|
||||
.touch div.control-componentlist div.components div.layout-cell>div:active:after,
|
||||
.touch div.control-componentlist div.components div.layout-cell>div:active:focus:after{text-shadow:0 0 1px #475354 !important;color:#fff !important}
|
||||
body.drag div.control-componentlist div.components div.layout-cell>div,
|
||||
body.drag div.control-componentlist div.components div.layout-cell>div:hover,
|
||||
body.drag div.control-componentlist div.components div.layout-cell>div:active{background:#fff;color:#475354}
|
||||
body.drag div.control-componentlist div.components div.layout-cell>div:after,
|
||||
body.drag div.control-componentlist div.components div.layout-cell>div:hover:after,
|
||||
body.drag div.control-componentlist div.components div.layout-cell>div:active:after{text-shadow:0 0 1px #475354 !important;color:#fff !important}
|
||||
33
modules/cms/assets/css/winter.theme-selector.css
Normal file
33
modules/cms/assets/css/winter.theme-selector.css
Normal file
@@ -0,0 +1,33 @@
|
||||
.theme-selector-layout .layout-cell{padding:24px;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}
|
||||
.theme-selector-layout .theme-thumbnail{width:288px;background:#ecf0f1;border-top:1px solid #e3e7e9}
|
||||
.theme-selector-layout .theme-thumbnail img{opacity:0.6;filter:alpha(opacity=60);width:240px}
|
||||
.theme-selector-layout .theme-description{border-top:1px solid #f2f3f4}
|
||||
.theme-selector-layout .theme-description h3,
|
||||
.theme-selector-layout .theme-description p{opacity:0.6;filter:alpha(opacity=60)}
|
||||
.theme-selector-layout .theme-description h3{margin:0 0 25px 0;font-size:28px;color:#2b3e50;display:inline-block}
|
||||
.theme-selector-layout .theme-description p.author{font-size:13px;display:inline-block;color:#808c8d}
|
||||
.theme-selector-layout .theme-description p.description{color:#2b3e50;font-size:14px;line-height:180%;margin-bottom:30px}
|
||||
.theme-selector-layout .theme-description .controls .btn>i{margin-right:5px;font-size:16px;position:relative;top:1px}
|
||||
.theme-selector-layout .theme-description .controls .btn>i.icon-star{color:#f1a84e}
|
||||
.theme-selector-layout .theme-description .controls .dropdown{display:inline-block}
|
||||
.theme-selector-layout .layout-row.active .theme-thumbnail{background:#bdc3c7;border-top-color:#bdc3c7}
|
||||
.theme-selector-layout .layout-row.active .thumbnail-container{position:relative}
|
||||
.theme-selector-layout .layout-row.active .thumbnail-container:after{content:'';display:block;width:0;height:0;border-top:14px solid transparent;border-bottom:14px solid transparent;border-left:15px solid #bdc3c7;position:absolute;right:-35px;top:50%;margin-top:-14px}
|
||||
.theme-selector-layout .layout-row.active .theme-description h3,
|
||||
.theme-selector-layout .layout-row:hover .theme-description h3,
|
||||
.theme-selector-layout .layout-row.active .theme-description p,
|
||||
.theme-selector-layout .layout-row:hover .theme-description p{opacity:1;filter:alpha(opacity=100)}
|
||||
.theme-selector-layout .layout-row.active .theme-thumbnail img,
|
||||
.theme-selector-layout .layout-row:hover .theme-thumbnail img{opacity:1;filter:alpha(opacity=100)}
|
||||
.theme-selector-layout .layout-row:first-child .theme-description,
|
||||
.theme-selector-layout .layout-row.links .theme-description,
|
||||
.theme-selector-layout .layout-row:first-child .theme-thumbnail,
|
||||
.theme-selector-layout .layout-row.links .theme-thumbnail{border-top:none}
|
||||
.theme-selector-layout .layout-row.links .theme-thumbnail{border-bottom:1px solid #e3e7e9}
|
||||
.theme-selector-layout .layout-row.links .theme-description{border-bottom:1px solid #f2f3f4}
|
||||
.theme-selector-layout .create-new-theme{margin-bottom:10px}
|
||||
.theme-selector-layout .create-new-theme,
|
||||
.theme-selector-layout .find-more-themes{background:#ecf0f1;color:#2b3e50;text-decoration:none;display:block;padding:20px;-webkit-border-radius:4px;-moz-border-radius:4px;border-radius:4px}
|
||||
.theme-selector-layout .create-new-theme:hover,
|
||||
.theme-selector-layout .find-more-themes:hover{background:#2da7c7;color:white}
|
||||
@media (max-width:768px){.theme-selector-layout .layout-cell,.theme-selector-layout .layout-row{display:block!important;width:auto!important;height:auto!important}.theme-selector-layout .theme-thumbnail img{width:100%}.theme-selector-layout .layout-row.links .theme-thumbnail{background:transparent;padding:0}}
|
||||
27
modules/cms/assets/images/cms-icon.svg
Normal file
27
modules/cms/assets/images/cms-icon.svg
Normal file
@@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg width="65px" height="64px" viewBox="0 0 65 64" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:sketch="http://www.bohemiancoding.com/sketch/ns">
|
||||
<!-- Generator: Sketch 3.4.4 (17249) - http://www.bohemiancoding.com/sketch -->
|
||||
<title>cms-icon</title>
|
||||
<desc>Created with Sketch.</desc>
|
||||
<defs></defs>
|
||||
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" sketch:type="MSPage">
|
||||
<g id="Group" sketch:type="MSLayerGroup">
|
||||
<path d="M0.754,3.84 C0.754,1.719 2.473,0 4.594,0 L32.754,0 L48.754,13.44 L48.754,60.16 C48.754,62.281 47.035,64 44.914,64 L4.594,64 C2.473,64 0.754,62.281 0.754,60.16 L0.754,3.84 Z" id="Fill-637" fill="#FFFFFF" sketch:type="MSShapeGroup"></path>
|
||||
<path d="M32.754,0 L32.754,9.6 C32.754,11.721 34.473,13.44 36.594,13.44 L48.754,13.44 L32.754,0 Z" id="Fill-638" fill="#F0F1F1" sketch:type="MSShapeGroup"></path>
|
||||
<path d="M29.754,47 L39.754,47 C40.305,47 40.754,46.552 40.754,46 L40.754,41 C40.754,40.448 40.305,40 39.754,40 L29.754,40 C29.203,40 28.754,40.448 28.754,41 L28.754,46 C28.754,46.552 29.203,47 29.754,47" id="Fill-639" fill="#E2E4E5" sketch:type="MSShapeGroup"></path>
|
||||
<path d="M29.754,40 C29.203,40 28.754,40.448 28.754,41 L28.754,46 C28.754,46.552 29.203,47 29.754,47 L39.754,47 C40.305,47 40.754,46.552 40.754,46 L40.754,41 C40.754,40.448 40.305,40 39.754,40 L29.754,40 Z M39.754,49 L29.754,49 C28.1,49 26.754,47.654 26.754,46 L26.754,41 C26.754,39.346 28.1,38 29.754,38 L39.754,38 C41.408,38 42.754,39.346 42.754,41 L42.754,46 C42.754,47.654 41.408,49 39.754,49 L39.754,49 Z" id="Fill-640" fill="#B6BCBD" sketch:type="MSShapeGroup"></path>
|
||||
<path d="M7.754,47 L17.754,47 C18.305,47 18.754,46.552 18.754,46 L18.754,41 C18.754,40.448 18.305,40 17.754,40 L7.754,40 C7.203,40 6.754,40.448 6.754,41 L6.754,46 C6.754,46.552 7.203,47 7.754,47" id="Fill-641" fill="#E2E4E5" sketch:type="MSShapeGroup"></path>
|
||||
<path d="M7.754,40 C7.203,40 6.754,40.448 6.754,41 L6.754,46 C6.754,46.552 7.203,47 7.754,47 L17.754,47 C18.305,47 18.754,46.552 18.754,46 L18.754,41 C18.754,40.448 18.305,40 17.754,40 L7.754,40 Z M17.754,49 L7.754,49 C6.1,49 4.754,47.654 4.754,46 L4.754,41 C4.754,39.346 6.1,38 7.754,38 L17.754,38 C19.408,38 20.754,39.346 20.754,41 L20.754,46 C20.754,47.654 19.408,49 17.754,49 L17.754,49 Z" id="Fill-642" fill="#B6BCBD" sketch:type="MSShapeGroup"></path>
|
||||
<path d="M18.754,27 L28.754,27 C29.305,27 29.754,26.552 29.754,26 L29.754,21 C29.754,20.448 29.305,20 28.754,20 L18.754,20 C18.203,20 17.754,20.448 17.754,21 L17.754,26 C17.754,26.552 18.203,27 18.754,27" id="Fill-643" fill="#9CE5F4" sketch:type="MSShapeGroup"></path>
|
||||
<path d="M28.026,29 L25.768,29 L30.482,38 L32.74,38 L28.026,29 Z" id="Fill-644" fill="#CFD3D4" sketch:type="MSShapeGroup"></path>
|
||||
<path d="M19.482,29 L14.768,38 L17.026,38 L21.74,29 L19.482,29 Z" id="Fill-645" fill="#CFD3D4" sketch:type="MSShapeGroup"></path>
|
||||
<path d="M18.754,20 C18.203,20 17.754,20.448 17.754,21 L17.754,26 C17.754,26.552 18.203,27 18.754,27 L28.754,27 C29.305,27 29.754,26.552 29.754,26 L29.754,21 C29.754,20.448 29.305,20 28.754,20 L18.754,20 Z M28.754,29 L18.754,29 C17.1,29 15.754,27.654 15.754,26 L15.754,21 C15.754,19.346 17.1,18 18.754,18 L28.754,18 C30.408,18 31.754,19.346 31.754,21 L31.754,26 C31.754,27.654 30.408,29 28.754,29 L28.754,29 Z" id="Fill-646" fill="#40C9E7" sketch:type="MSShapeGroup"></path>
|
||||
<path d="M61.8979,34.4983 L40.6949,58.4653 L34.8179,59.6753 C34.6479,58.8573 34.2249,58.0823 33.5509,57.4863 C32.8769,56.8893 32.0559,56.5643 31.2229,56.4953 L31.7069,50.5143 L52.9099,26.5473 L61.8979,34.4983 Z" id="Fill-647" fill="#F4D0A1" sketch:type="MSShapeGroup"></path>
|
||||
<path d="M61.8692,34.4729 C61.8852,34.4869 61.8862,34.5109 61.8722,34.5269 L41.1512,57.9499 C41.1182,57.9879 41.0652,57.9479 41.0892,57.9029 C41.7392,56.7029 41.5032,55.1759 40.4362,54.2309 C39.3092,53.2349 37.6512,53.2359 36.5312,54.1689 C36.4992,54.1959 36.4562,54.1589 36.4792,54.1229 C37.2692,52.8979 37.0682,51.2519 35.9422,50.2549 C34.8622,49.3009 33.2942,49.2619 32.1802,50.0819 C32.1442,50.1089 32.1032,50.0669 32.1332,50.0339 L52.8852,26.5759 C52.8992,26.5599 52.9232,26.5589 52.9392,26.5729 L61.8692,34.4729 Z" id="Fill-648" fill="#059BBF" sketch:type="MSShapeGroup"></path>
|
||||
<path d="M61.8979,34.4983 L52.9099,26.5473 L54.2349,25.0493 L63.2229,33.0003 L61.8979,34.4983 Z" id="Fill-649" fill="#FACB1B" sketch:type="MSShapeGroup"></path>
|
||||
<path d="M62.7497,24.5706 L62.6597,24.4916 C60.2027,22.3176 56.4487,22.5476 54.2747,25.0046 L54.2357,25.0496 L63.2227,33.0006 L63.2627,32.9556 C65.4367,30.4986 65.2067,26.7446 62.7497,24.5706" id="Fill-650" fill="#F89392" sketch:type="MSShapeGroup"></path>
|
||||
<path d="M61.8728,34.5266 L41.1258,57.9796 C41.0948,58.0136 41.0478,57.9776 41.0698,57.9376 C41.7438,56.7326 41.5138,55.1846 40.4358,54.2306 C39.3118,53.2366 37.6588,53.2356 36.5388,54.1626 C36.5198,54.1786 36.4978,54.1546 36.5138,54.1366 L57.3788,30.5516 C57.3928,30.5356 57.4168,30.5336 57.4328,30.5476 L61.8698,34.4736 C61.8848,34.4866 61.8868,34.5106 61.8728,34.5266" id="Fill-651" fill="#0484AB" sketch:type="MSShapeGroup"></path>
|
||||
<path d="M34.8181,59.6754 L30.9001,60.4824 L31.2231,56.4954 C32.0561,56.5644 32.8771,56.8894 33.5511,57.4864 C34.2251,58.0824 34.6481,58.8574 34.8181,59.6754" id="Fill-652" fill="#3E3E3F" sketch:type="MSShapeGroup"></path>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.5 KiB |
BIN
modules/cms/assets/images/default-theme-preview.png
Normal file
BIN
modules/cms/assets/images/default-theme-preview.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
113
modules/cms/assets/js/themelogs/template-diff.js
Normal file
113
modules/cms/assets/js/themelogs/template-diff.js
Normal file
@@ -0,0 +1,113 @@
|
||||
/*
|
||||
* Template Diff plugin
|
||||
*
|
||||
* Data attributes:
|
||||
* - data-plugin="template-diff" - enables the plugin on an element
|
||||
*
|
||||
* JavaScript API:
|
||||
* $('pre').templateDiff({ option: 'value' })
|
||||
*
|
||||
* Dependences:
|
||||
* - jsdiff (diff.js)
|
||||
*/
|
||||
|
||||
+function ($) { "use strict";
|
||||
|
||||
// TEMPALTE DIFF CLASS DEFINITION
|
||||
// ============================
|
||||
|
||||
var TemplateDiff = function(element, options) {
|
||||
this.options = options
|
||||
this.$el = $(element)
|
||||
|
||||
// Init
|
||||
this.init()
|
||||
}
|
||||
|
||||
TemplateDiff.DEFAULTS = {
|
||||
oldFieldName: null,
|
||||
newFieldName: null,
|
||||
contentTag: '',
|
||||
diffType: 'lines' // chars, words, lines
|
||||
}
|
||||
|
||||
TemplateDiff.prototype.init = function() {
|
||||
var
|
||||
oldValue = $('[data-field-name="'+this.options.oldFieldName+'"] .form-control '+this.options.contentTag).html(),
|
||||
newValue = $('[data-field-name="'+this.options.newFieldName+'"] .form-control '+this.options.contentTag).html()
|
||||
|
||||
oldValue = $('<div />').html(oldValue).text()
|
||||
newValue = $('<div />').html(newValue).text()
|
||||
|
||||
this.diffStrings(oldValue, newValue)
|
||||
}
|
||||
|
||||
TemplateDiff.prototype.diffStrings = function(oldValue, newValue) {
|
||||
var result = this.$el.get(0)
|
||||
var diffType = 'diff' + this.options.diffType[0].toUpperCase() + this.options.diffType.slice(1)
|
||||
var diff = JsDiff[diffType](oldValue, newValue)
|
||||
var fragment = document.createDocumentFragment();
|
||||
for (var i=0; i < diff.length; i++) {
|
||||
|
||||
if (diff[i].added && diff[i + 1] && diff[i + 1].removed) {
|
||||
var swap = diff[i];
|
||||
diff[i] = diff[i + 1];
|
||||
diff[i + 1] = swap;
|
||||
}
|
||||
|
||||
var node;
|
||||
if (diff[i].removed) {
|
||||
node = document.createElement('del');
|
||||
node.appendChild(document.createTextNode(diff[i].value));
|
||||
}
|
||||
else if (diff[i].added) {
|
||||
node = document.createElement('ins');
|
||||
node.appendChild(document.createTextNode(diff[i].value));
|
||||
}
|
||||
else {
|
||||
node = document.createTextNode(diff[i].value);
|
||||
}
|
||||
fragment.appendChild(node);
|
||||
}
|
||||
|
||||
result.textContent = '';
|
||||
result.appendChild(fragment);
|
||||
}
|
||||
|
||||
// TEMPALTE DIFF PLUGIN DEFINITION
|
||||
// ============================
|
||||
|
||||
var old = $.fn.templateDiff
|
||||
|
||||
$.fn.templateDiff = function (option) {
|
||||
var args = Array.prototype.slice.call(arguments, 1), result
|
||||
this.each(function () {
|
||||
var $this = $(this)
|
||||
var data = $this.data('oc.example')
|
||||
var options = $.extend({}, TemplateDiff.DEFAULTS, $this.data(), typeof option == 'object' && option)
|
||||
if (!data) $this.data('oc.example', (data = new TemplateDiff(this, options)))
|
||||
if (typeof option == 'string') result = data[option].apply(data, args)
|
||||
if (typeof result != 'undefined') return false
|
||||
})
|
||||
|
||||
return result ? result : this
|
||||
}
|
||||
|
||||
$.fn.templateDiff.Constructor = TemplateDiff
|
||||
|
||||
// TEMPALTE DIFF NO CONFLICT
|
||||
// =================
|
||||
|
||||
$.fn.templateDiff.noConflict = function () {
|
||||
$.fn.templateDiff = old
|
||||
return this
|
||||
}
|
||||
|
||||
// TEMPALTE DIFF DATA-API
|
||||
// ===============
|
||||
|
||||
$(document).render(function () {
|
||||
$('[data-plugin="template-diff"]').templateDiff()
|
||||
});
|
||||
|
||||
}(window.jQuery);
|
||||
701
modules/cms/assets/js/winter.cmspage.js
Normal file
701
modules/cms/assets/js/winter.cmspage.js
Normal file
@@ -0,0 +1,701 @@
|
||||
/*
|
||||
* Scripts for the CMS page.
|
||||
*/
|
||||
+function ($) { "use strict";
|
||||
|
||||
var Base = $.wn.foundation.base,
|
||||
BaseProto = Base.prototype
|
||||
|
||||
var CmsPage = function() {
|
||||
|
||||
Base.call(this)
|
||||
|
||||
//
|
||||
// Initialization
|
||||
//
|
||||
|
||||
this.init()
|
||||
this.widgets = Snowboard['backend.ui.widgetHandler']()
|
||||
}
|
||||
|
||||
CmsPage.prototype = Object.create(BaseProto)
|
||||
CmsPage.prototype.constructor = CmsPage
|
||||
|
||||
CmsPage.prototype.init = function() {
|
||||
$(document).ready(this.proxy(this.registerHandlers))
|
||||
}
|
||||
|
||||
CmsPage.prototype.updateTemplateList = function(type) {
|
||||
var $form = $('#cms-side-panel form[data-template-type='+type+']'),
|
||||
templateList = type + 'List'
|
||||
|
||||
$form.request(templateList + '::onUpdate', {
|
||||
complete: function() {
|
||||
$('button[data-control=delete-template]', $form).trigger('oc.triggerOn.update')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
CmsPage.prototype.registerHandlers = function() {
|
||||
var $document = $(document),
|
||||
$masterTabs = $('#cms-master-tabs')
|
||||
|
||||
$masterTabs.on('closed.oc.tab', this.proxy(this.onTabClosed))
|
||||
$masterTabs.on('beforeClose.oc.tab', this.proxy(this.onBeforeTabClose))
|
||||
$masterTabs.on('oc.beforeRequest', this.proxy(this.onBeforeRequest))
|
||||
$masterTabs.on('shown.bs.tab', this.proxy(this.onTabShown))
|
||||
$masterTabs.on('initTab.oc.tab', this.proxy(this.onInitTab))
|
||||
$masterTabs.on('afterAllClosed.oc.tab', this.proxy(this.onAfterAllTabsClosed))
|
||||
|
||||
$(window).on('ajaxInvalidField', this.proxy(this.ajaxInvalidField))
|
||||
$document.on('open.oc.list', '#cms-side-panel', this.proxy(this.onOpenDocument))
|
||||
$document.on('ajaxUpdate', '[data-control=filelist], [data-control=assetlist]', this.proxy(this.onAjaxUpdate))
|
||||
$document.on('ajaxError', '#cms-master-tabs form', this.proxy(this.onAjaxError))
|
||||
$document.on('ajaxSuccess', '#cms-master-tabs form', this.proxy(this.onAjaxSuccess))
|
||||
$document.on('click', '#cms-side-panel form button[data-control=create-template], #cms-side-panel form li a[data-control=create-template]', this.proxy(this.onCreateTemplateClick))
|
||||
$document.on('click', '#cms-side-panel form button[data-control=delete-template]', this.proxy(this.onDeleteTemplateClick))
|
||||
$document.on('showing.oc.inspector', '[data-inspectable]', this.proxy(this.onInspectorShowing))
|
||||
$document.on('hidden.oc.inspector', '[data-inspectable]', this.proxy(this.onInspectorHidden))
|
||||
$document.on('hiding.oc.inspector', '[data-inspectable]', this.proxy(this.onInspectorHiding))
|
||||
$document.on('click', '#cms-master-tabs > div.tab-content > .tab-pane.active .control-componentlist a.remove', this.proxy(this.onComponentRemove))
|
||||
$document.on('click', '#cms-component-list [data-component]', this.proxy(this.onComponentClick))
|
||||
|
||||
// Watch for PHP editors
|
||||
window.Snowboard.on('backend.formwidget.codeeditor.create', this.proxy(this.onCodeEditorCreate));
|
||||
}
|
||||
|
||||
// EVENT HANDLERS
|
||||
// ============================
|
||||
|
||||
CmsPage.prototype.onOpenDocument = function(event) {
|
||||
/*
|
||||
* Open a document when it's clicked in the sidebar
|
||||
*/
|
||||
|
||||
var $item = $(event.relatedTarget),
|
||||
$form = $item.closest('[data-template-type]'),
|
||||
data = {
|
||||
type: $form.data('template-type'),
|
||||
theme: $item.data('item-theme'),
|
||||
path: $item.data('item-path')
|
||||
},
|
||||
tabId = data.type + '-' + data.theme + '-' + data.path
|
||||
|
||||
if (data.type == 'asset' && $item.data('editable') === undefined)
|
||||
return true
|
||||
|
||||
if ($form.length == 0)
|
||||
return false
|
||||
|
||||
/*
|
||||
* Find if the tab is already opened
|
||||
*/
|
||||
if ($('#cms-master-tabs').data('oc.tab').goTo(tabId))
|
||||
return false
|
||||
|
||||
/*
|
||||
* Open a new tab
|
||||
*/
|
||||
$.wn.stripeLoadIndicator.show()
|
||||
|
||||
$form.request('onOpenTemplate', {
|
||||
data: data
|
||||
}).done(function(data) {
|
||||
$.wn.stripeLoadIndicator.hide()
|
||||
$('#cms-master-tabs').ocTab('addTab', data.tabTitle, data.tab, tabId, $form.data('type-icon'))
|
||||
}).always(function() {
|
||||
$.wn.stripeLoadIndicator.hide()
|
||||
}).fail(function(jqXHR, textStatus, errorThrown) {
|
||||
$.wn.stripeLoadIndicator.hide()
|
||||
})
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
CmsPage.prototype.ajaxInvalidField = function(ev, element, name, messages, isFirst) {
|
||||
/*
|
||||
* Detect invalid fields, uncollapse the panel
|
||||
*/
|
||||
if (!isFirst)
|
||||
return
|
||||
|
||||
ev.preventDefault()
|
||||
|
||||
var $el = $(element),
|
||||
$panel = $el.closest('.form-tabless-fields.collapsed'),
|
||||
$primaryPanel = $el.closest('.control-tabs.primary-tabs.collapsed')
|
||||
|
||||
if ($panel.length > 0)
|
||||
$panel.removeClass('collapsed')
|
||||
|
||||
if ($primaryPanel.length > 0) {
|
||||
$primaryPanel.removeClass('collapsed')
|
||||
|
||||
var pane = $primaryPanel.closest('.tab-pane'),
|
||||
$secondaryPanel = $('.control-tabs.secondary-tabs', pane)
|
||||
|
||||
$secondaryPanel.removeClass('primary-collapsed')
|
||||
}
|
||||
|
||||
$el.focus()
|
||||
}
|
||||
|
||||
CmsPage.prototype.onTabClosed = function(ev) {
|
||||
this.updateModifiedCounter()
|
||||
|
||||
if ($('> div.tab-content > div.tab-pane', '#cms-master-tabs').length == 0)
|
||||
this.setPageTitle('')
|
||||
}
|
||||
|
||||
CmsPage.prototype.onBeforeTabClose = function(ev) {
|
||||
if ($.fn.table !== undefined)
|
||||
$('[data-control=table]', ev.relatedTarget).table('dispose')
|
||||
|
||||
$.wn.foundation.controlUtils.disposeControls(ev.relatedTarget.get(0))
|
||||
}
|
||||
|
||||
CmsPage.prototype.onBeforeRequest = function(ev) {
|
||||
var $form = $(ev.target)
|
||||
|
||||
if ($('.components .layout-cell.error-component', $form).length > 0) {
|
||||
if (!confirm('The form contains unknown components. Their properties will be lost on save. Do you want to save the form?'))
|
||||
ev.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
CmsPage.prototype.onTabShown = function(ev) {
|
||||
/*
|
||||
* Listen for the tabs "shown" event to track the current template in the list
|
||||
*/
|
||||
|
||||
var $target = $(ev.target)
|
||||
|
||||
if ($target.closest('[data-control=tab]').attr('id') != 'cms-master-tabs')
|
||||
return
|
||||
|
||||
var dataId = $target.closest('li').attr('data-tab-id'),
|
||||
title = $target.attr('title'),
|
||||
$sidePanel = $('#cms-side-panel')
|
||||
|
||||
if (title)
|
||||
this.setPageTitle(title)
|
||||
|
||||
$sidePanel.find('[data-control=filelist]').fileList('markActive', dataId)
|
||||
$sidePanel.find('form').trigger('oc.list.setActiveItem', [dataId])
|
||||
}
|
||||
|
||||
CmsPage.prototype.onInitTab = function(ev, data) {
|
||||
/*
|
||||
* Listen for the tabs "initTab" event to inject extra controls to the tab
|
||||
*/
|
||||
|
||||
if ($(ev.target).attr('id') != 'cms-master-tabs')
|
||||
return
|
||||
|
||||
var $collapseIcon = $('<a href="javascript:;" class="tab-collapse-icon tabless"><i class="icon-chevron-up"></i></a>'),
|
||||
$panel = $('.form-tabless-fields', data.pane)
|
||||
|
||||
$panel.append($collapseIcon);
|
||||
|
||||
$collapseIcon.click(function(){
|
||||
$panel.toggleClass('collapsed')
|
||||
|
||||
if (typeof(localStorage) !== 'undefined')
|
||||
localStorage.ocCmsTablessCollapsed = $panel.hasClass('collapsed') ? 1 : 0
|
||||
|
||||
window.setTimeout(function(){
|
||||
$(window).trigger('oc.updateUi')
|
||||
}, 500)
|
||||
|
||||
return false
|
||||
})
|
||||
|
||||
var $primaryCollapseIcon = $('<a href="javascript:;" class="tab-collapse-icon primary"><i class="icon-chevron-down"></i></a>'),
|
||||
$primaryPanel = $('.control-tabs.primary-tabs', data.pane),
|
||||
$secondaryPanel = $('.control-tabs.secondary-tabs', data.pane)
|
||||
|
||||
if ($primaryPanel.length > 0) {
|
||||
$secondaryPanel.append($primaryCollapseIcon);
|
||||
|
||||
$primaryCollapseIcon.click(function(){
|
||||
$primaryPanel.toggleClass('collapsed')
|
||||
$secondaryPanel.toggleClass('primary-collapsed')
|
||||
$(window).trigger('oc.updateUi')
|
||||
if (typeof(localStorage) !== 'undefined')
|
||||
localStorage.ocCmsPrimaryCollapsed = $primaryPanel.hasClass('collapsed') ? 1 : 0
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
if (typeof(localStorage) !== 'undefined') {
|
||||
if (!$('a', data.tab).hasClass('new-template') && localStorage.ocCmsTablessCollapsed == 1)
|
||||
$panel.addClass('collapsed')
|
||||
|
||||
if (localStorage.ocCmsPrimaryCollapsed == 1) {
|
||||
$primaryPanel.addClass('collapsed')
|
||||
$secondaryPanel.addClass('primary-collapsed')
|
||||
}
|
||||
}
|
||||
|
||||
var $componentListFormGroup = $('.control-componentlist', data.pane).closest('.form-group')
|
||||
if ($primaryPanel.length > 0)
|
||||
$primaryPanel.before($componentListFormGroup)
|
||||
else
|
||||
$secondaryPanel.parent().before($componentListFormGroup)
|
||||
|
||||
$componentListFormGroup.removeClass()
|
||||
$componentListFormGroup.addClass('layout-row min-size')
|
||||
this.updateComponentListClass(data.pane)
|
||||
|
||||
var $form = $('form', data.pane),
|
||||
self = this
|
||||
|
||||
$form.on('changed.oc.changeMonitor', function() {
|
||||
$panel.trigger('modified.oc.tab')
|
||||
$panel.find('[data-control=commit-button]').addClass('hide');
|
||||
$panel.find('[data-control=reset-button]').addClass('hide');
|
||||
self.updateModifiedCounter()
|
||||
})
|
||||
|
||||
$form.on('unchanged.oc.changeMonitor', function() {
|
||||
$panel.trigger('unmodified.oc.tab')
|
||||
self.updateModifiedCounter()
|
||||
})
|
||||
}
|
||||
|
||||
CmsPage.prototype.onCodeEditorCreate = function (widget, editor) {
|
||||
const $form = $(widget.element.closest('form'));
|
||||
|
||||
if (widget.config.get('language') === 'php') {
|
||||
let value = widget.getValue();
|
||||
|
||||
// If no PHP tag at the start, prepend one
|
||||
if (!/^<\?php\s*/.test(value)) {
|
||||
widget.setValue('<?php\n' + value);
|
||||
}
|
||||
|
||||
// Verify the editor has at least 2 lines before hiding line 1
|
||||
const lineCount = widget.getModel().getLineCount();
|
||||
if (lineCount >= 2) {
|
||||
// Only hide line 1 if it contains just the PHP open tag
|
||||
const firstLine = widget.getModel().getLineContent(1);
|
||||
if (/^<\?php\s*$/.test(firstLine)) {
|
||||
widget.fromLine(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add codelens and action to customise component templates
|
||||
const templateCommand = editor.addCommand(
|
||||
0,
|
||||
function (command, range, name) {
|
||||
$form.request('onExpandMarkupToken', {
|
||||
data: {
|
||||
tokenType: 'component',
|
||||
tokenName: name,
|
||||
},
|
||||
success: function (data) {
|
||||
if (data.result) {
|
||||
widget.replace(range, data.result)
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
widget.addCodeLens(
|
||||
'twig',
|
||||
function (model, token) {
|
||||
// Find component tags
|
||||
const lenses = [];
|
||||
const matches = model.findMatches('\\{%\\scomponent\\s[\'"]([^\'"]+)[\'"][^%]*\\s%\\}', true, true, false, null, true);
|
||||
|
||||
matches.forEach((match) => {
|
||||
const name = match.matches[1] ?? 'unknown';
|
||||
lenses.push({
|
||||
range: match.range,
|
||||
id: 'component-' + name + '-lens',
|
||||
command: {
|
||||
title: 'Customize template',
|
||||
id: templateCommand,
|
||||
arguments: [match.range, name]
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
lenses: lenses,
|
||||
dispose: function () {
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
CmsPage.prototype.onAfterAllTabsClosed = function(ev) {
|
||||
var $sidePanel = $('#cms-side-panel')
|
||||
|
||||
$sidePanel.find('[data-control=filelist]').fileList('markActive', null)
|
||||
$sidePanel.find('form').trigger('oc.list.setActiveItem', [null])
|
||||
}
|
||||
|
||||
CmsPage.prototype.onAjaxUpdate = function(ev) {
|
||||
var dataId = $('#cms-master-tabs .nav-tabs li.active').attr('data-tab-id'),
|
||||
$sidePanel = $('#cms-side-panel')
|
||||
|
||||
$sidePanel.find('[data-control=filelist]').fileList('markActive', dataId)
|
||||
$sidePanel.find('form').trigger('oc.list.setActiveItem', [dataId])
|
||||
}
|
||||
|
||||
CmsPage.prototype.onAjaxSuccess = function(ev, context, data) {
|
||||
var element = ev.target
|
||||
|
||||
// Update the visibilities of the commit & reset buttons
|
||||
$('[data-control=commit-button]', element).toggleClass('hide', !data.canCommit)
|
||||
$('[data-control=reset-button]', element).toggleClass('hide', !data.canReset)
|
||||
|
||||
if (data.templatePath !== undefined) {
|
||||
$('input[name=templatePath]', element).val(data.templatePath)
|
||||
$('input[name=templateMtime]', element).val(data.templateMtime)
|
||||
$('[data-control=delete-button]', element).removeClass('hide')
|
||||
$('[data-control=preview-button]', element).removeClass('hide')
|
||||
|
||||
if (data.pageUrl !== undefined)
|
||||
$('[data-control=preview-button]', element).attr('href', data.pageUrl)
|
||||
}
|
||||
|
||||
if (data.tabTitle !== undefined) {
|
||||
$('#cms-master-tabs').ocTab('updateTitle', $(element).closest('.tab-pane'), data.tabTitle)
|
||||
this.setPageTitle(data.tabTitle)
|
||||
}
|
||||
|
||||
var tabId = $('input[name=templateType]', element).val() + '-'
|
||||
+ $('input[name=theme]', element).val() + '-'
|
||||
+ $('input[name=templatePath]', element).val();
|
||||
|
||||
$('#cms-master-tabs').ocTab('updateIdentifier', $(element).closest('.tab-pane'), tabId)
|
||||
|
||||
var templateType = $('input[name=templateType]', element).val()
|
||||
if (templateType.length > 0) {
|
||||
$.wn.cmsPage.updateTemplateList(templateType)
|
||||
|
||||
if (templateType == 'layout')
|
||||
this.updateLayouts(element)
|
||||
}
|
||||
|
||||
if (context.handler == 'onSave' && (!data['X_WINTER_ERROR_FIELDS'] && !data['X_WINTER_ERROR_MESSAGE'])) {
|
||||
$(element).trigger('unchange.oc.changeMonitor')
|
||||
}
|
||||
|
||||
// Reload the form if the server has requested it
|
||||
if (data.forceReload) {
|
||||
this.reloadForm(element)
|
||||
}
|
||||
}
|
||||
|
||||
CmsPage.prototype.onAjaxError = function(ev, context, message, data, jqXHR) {
|
||||
if (context.handler == 'onSave') {
|
||||
if (jqXHR.responseText == 'mtime-mismatch') {
|
||||
ev.preventDefault()
|
||||
this.handleMtimeMismatch(ev.target)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CmsPage.prototype.onCreateTemplateClick = function(ev) {
|
||||
var $form = $(ev.target).closest('[data-template-type]'),
|
||||
type = $form.data('template-type'),
|
||||
tabId = type + Math.random(),
|
||||
self = this
|
||||
|
||||
$.wn.stripeLoadIndicator.show()
|
||||
|
||||
$form.request('onCreateTemplate', {
|
||||
data: {type: type}
|
||||
}).done(function(data) {
|
||||
$('#cms-master-tabs').ocTab('addTab', data.tabTitle, data.tab, tabId, $form.data('type-icon') + ' new-template')
|
||||
$('#layout-side-panel').trigger('close.oc.sidePanel')
|
||||
self.setPageTitle(data.tabTitle)
|
||||
}).always(function(){
|
||||
$.wn.stripeLoadIndicator.hide()
|
||||
})
|
||||
}
|
||||
|
||||
CmsPage.prototype.onDeleteTemplateClick = function(ev) {
|
||||
var $el = $(ev.currentTarget),
|
||||
$form = $el.closest('form'),
|
||||
templateType = $form.data('template-type'),
|
||||
self = this
|
||||
|
||||
if (!confirm($el.data('confirmation')))
|
||||
return
|
||||
|
||||
$.wn.stripeLoadIndicator.show()
|
||||
|
||||
$form.request('onDeleteTemplates', {
|
||||
data: {type: templateType}
|
||||
}).done(function(data) {
|
||||
var tabs = $('#cms-master-tabs').data('oc.tab');
|
||||
$.each(data.deleted, function(index, path){
|
||||
var
|
||||
tabId = templateType + '-' + data.theme + '-' + path,
|
||||
tab = tabs.findByIdentifier(tabId)
|
||||
|
||||
$('#cms-master-tabs').ocTab('closeTab', tab, true)
|
||||
})
|
||||
|
||||
if (data.error !== undefined && $.type(data.error) === 'string' && data.error.length)
|
||||
$.wn.flashMsg({text: data.error, 'class': 'error'})
|
||||
}).always(function(){
|
||||
self.updateTemplateList(templateType)
|
||||
$.wn.stripeLoadIndicator.hide()
|
||||
})
|
||||
}
|
||||
|
||||
CmsPage.prototype.onInspectorShowing = function(ev, data) {
|
||||
var $dragScroll = $(ev.currentTarget).closest('[data-control="toolbar"]').data('oc.dragScroll')
|
||||
if ($dragScroll) {
|
||||
$dragScroll.goToElement(ev.currentTarget, data.callback)
|
||||
} else {
|
||||
data.callback();
|
||||
}
|
||||
|
||||
ev.stopPropagation()
|
||||
}
|
||||
|
||||
CmsPage.prototype.onInspectorHidden = function(ev) {
|
||||
var element = ev.target,
|
||||
values = JSON.parse($('[data-inspector-values]', element).val())
|
||||
|
||||
$('[name="component_aliases[]"]', element).val(values['oc.alias'])
|
||||
$('span.alias', element).text(values['oc.alias'])
|
||||
}
|
||||
|
||||
CmsPage.prototype.onInspectorHiding = function(ev, values) {
|
||||
var element = ev.target,
|
||||
values = JSON.parse($('[data-inspector-values]', element).val()),
|
||||
alias = values['oc.alias'],
|
||||
$componentList = $('#cms-master-tabs > div.tab-content > .tab-pane.active .control-componentlist .layout'),
|
||||
$cell = $(ev.target).parent()
|
||||
|
||||
$('div.layout-cell', $componentList).each(function(){
|
||||
if ($cell.get(0) == this)
|
||||
return true
|
||||
|
||||
var $input = $('input[name="component_aliases[]"]', this)
|
||||
|
||||
if ($input.val() == alias) {
|
||||
ev.preventDefault()
|
||||
alert('The component alias "'+alias+'" is already used.')
|
||||
return false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
CmsPage.prototype.onComponentRemove = function(ev) {
|
||||
var element = ev.currentTarget
|
||||
|
||||
$(element).trigger('change')
|
||||
var pane = $(element).closest('.tab-pane'),
|
||||
component = $(element).closest('div.layout-cell')
|
||||
|
||||
/*
|
||||
* Remove any {% component %} tags in the editor for this component
|
||||
*/
|
||||
var editor = $('[data-control=codeeditor]', pane)
|
||||
if (editor.length) {
|
||||
var alias = $('input[name="component_aliases[]"]', component).val().replace(/^@/, ''),
|
||||
codeEditor = this.widgets.getWidget(editor.get(0))
|
||||
|
||||
codeEditor.replace(new RegExp('\\{% +component +\'' + alias + '\'.*?%\\}'), '');
|
||||
}
|
||||
|
||||
component.remove()
|
||||
$(window).trigger('oc.updateUi')
|
||||
|
||||
this.updateComponentListClass(pane)
|
||||
return false
|
||||
}
|
||||
|
||||
CmsPage.prototype.onComponentClick = function(ev) {
|
||||
/*
|
||||
* Determine if a page or layout is open in the master tabs
|
||||
*/
|
||||
|
||||
var $componentList = $('#cms-master-tabs > div.tab-content > .tab-pane.active .control-componentlist .layout')
|
||||
if ($componentList.length == 0) {
|
||||
alert('Components can be added only to pages, partials and layouts.')
|
||||
return;
|
||||
}
|
||||
|
||||
var $component = $(ev.currentTarget).clone(),
|
||||
$iconInput = $component.find('[data-component-icon]'),
|
||||
$componentContainer = $('.layout-relative', $component),
|
||||
$configInput = $component.find('[data-inspector-config]'),
|
||||
$aliasInput = $component.find('[data-component-default-alias]'),
|
||||
$valuesInput = $component.find('[data-inspector-values]'),
|
||||
$nameInput = $component.find('[data-component-name]'),
|
||||
$classInput = $component.find('[data-inspector-class]'),
|
||||
alias = $aliasInput.val(),
|
||||
originalAlias = alias,
|
||||
counter = 2,
|
||||
existingAliases = []
|
||||
|
||||
$('div.layout-cell input[name="component_aliases[]"]', $componentList).each(function(){
|
||||
existingAliases.push($(this).val())
|
||||
})
|
||||
|
||||
while($.inArray(alias, existingAliases) !== -1) {
|
||||
alias = originalAlias + counter
|
||||
counter++
|
||||
}
|
||||
|
||||
// Set the last alias used so dragComponents can use it
|
||||
$('input[name="component_aliases[]"]', $(ev.currentTarget)).val(alias)
|
||||
|
||||
$component.attr('data-component-attached', true)
|
||||
$componentContainer.addClass($iconInput.val())
|
||||
$iconInput.remove()
|
||||
|
||||
$componentContainer.attr({
|
||||
'data-inspectable': '',
|
||||
'data-inspector-title': $component.find('span.name').text(),
|
||||
'data-inspector-description': $component.find('span.description').text(),
|
||||
'data-inspector-config': $configInput.val(),
|
||||
'data-inspector-class': $classInput.val()
|
||||
})
|
||||
|
||||
$configInput.remove()
|
||||
$('input[name="component_names[]"]', $component).val($nameInput.val())
|
||||
$nameInput.remove()
|
||||
$('input[name="component_aliases[]"]', $component).val(alias)
|
||||
$component.find('span.alias').text(alias)
|
||||
$valuesInput.val($valuesInput.val().replace('--alias--', alias))
|
||||
$aliasInput.remove()
|
||||
|
||||
$component.addClass('adding')
|
||||
$componentList.append($component)
|
||||
$componentList.closest('[data-control="toolbar"]').data('oc.dragScroll').goToElement($component)
|
||||
$component.removeClass('adding')
|
||||
$component.trigger('change')
|
||||
|
||||
this.updateComponentListClass($component.closest('.tab-pane'))
|
||||
|
||||
$(window).trigger('oc.updateUi')
|
||||
}
|
||||
|
||||
// INTERNAL METHODS
|
||||
// ============================
|
||||
|
||||
CmsPage.prototype.updateComponentListClass = function(pane) {
|
||||
var $componentList = $('.control-componentlist', pane),
|
||||
$primaryPanel = $('.control-tabs.primary-tabs', pane),
|
||||
$primaryTabContainer = $('.nav-tabs', $primaryPanel),
|
||||
hasComponents = $('.layout', $componentList).children(':not(.hidden)').length > 0
|
||||
|
||||
$primaryTabContainer.toggleClass('component-area', hasComponents)
|
||||
$componentList.toggleClass('has-components', hasComponents)
|
||||
}
|
||||
|
||||
CmsPage.prototype.updateModifiedCounter = function() {
|
||||
var counters = {
|
||||
page: { menu: 'pages', count: 0 },
|
||||
partial: { menu: 'partials', count: 0 },
|
||||
layout: { menu: 'layouts', count: 0 },
|
||||
content: { menu: 'content', count: 0 },
|
||||
asset: { menu: 'assets', count: 0}
|
||||
}
|
||||
|
||||
$('> div.tab-content > div.tab-pane[data-modified]', '#cms-master-tabs').each(function(){
|
||||
var inputType = $('> form > input[name=templateType]', this).val()
|
||||
counters[inputType].count++
|
||||
})
|
||||
|
||||
$.each(counters, function(type, data){
|
||||
$.wn.sideNav.setCounter('cms/' + data.menu, data.count);
|
||||
})
|
||||
}
|
||||
|
||||
CmsPage.prototype.handleMtimeMismatch = function(form) {
|
||||
var $form = $(form)
|
||||
$form.popup({ handler: 'onOpenConcurrencyResolveForm' })
|
||||
|
||||
var popup = $form.data('oc.popup'),
|
||||
self = this
|
||||
|
||||
$(popup.$content).on('click', 'button[data-action=reload]', function(){
|
||||
popup.hide()
|
||||
self.reloadForm(form)
|
||||
})
|
||||
|
||||
$(popup.$content).on('click', 'button[data-action=save]', function(){
|
||||
popup.hide()
|
||||
|
||||
$('input[name=templateForceSave]', $form).val(1)
|
||||
$('a[data-request=onSave]', $form).trigger('click')
|
||||
$('input[name=templateForceSave]', $form).val(0)
|
||||
})
|
||||
}
|
||||
|
||||
CmsPage.prototype.reloadForm = function(form) {
|
||||
var
|
||||
$form = $(form),
|
||||
data = {
|
||||
type: $('[name=templateType]', $form).val(),
|
||||
theme: $('[name=theme]', $form).val(),
|
||||
path: $('[name=templatePath]', $form).val(),
|
||||
},
|
||||
tabId = data.type + '-' + data.theme + '-' + data.path,
|
||||
tabs = $('#cms-master-tabs').data('oc.tab'),
|
||||
tab = tabs.findByIdentifier(tabId),
|
||||
self = this
|
||||
|
||||
/*
|
||||
* Update tab
|
||||
*/
|
||||
|
||||
$.wn.stripeLoadIndicator.show()
|
||||
|
||||
$form.request('onOpenTemplate', {
|
||||
data: data
|
||||
}).done(function(data) {
|
||||
$('#cms-master-tabs').ocTab('updateTab', tab, data.tabTitle, data.tab)
|
||||
$('#cms-master-tabs').ocTab('unmodifyTab', tab)
|
||||
self.updateModifiedCounter()
|
||||
}).always(function() {
|
||||
$.wn.stripeLoadIndicator.hide()
|
||||
}).fail(function(jqXHR, textStatus, errorThrown) {
|
||||
alert(jqXHR.responseText.length ? jqXHR.responseText : jqXHR.statusText)
|
||||
})
|
||||
}
|
||||
|
||||
CmsPage.prototype.setPageTitle = function(title) {
|
||||
if (title.length)
|
||||
$.wn.layout.setPageTitle(title + ' | ')
|
||||
else
|
||||
$.wn.layout.setPageTitle(title)
|
||||
}
|
||||
|
||||
CmsPage.prototype.updateLayouts = function(form) {
|
||||
$(form).request('onGetTemplateList', {
|
||||
success: function(data) {
|
||||
$('#cms-master-tabs > .tab-content select[name="settings[layout]"]').each(function(){
|
||||
var
|
||||
$select = $(this),
|
||||
value = $select.val()
|
||||
|
||||
$select.find('option').remove()
|
||||
$.each(data.layouts, function(layoutFile, layoutName){
|
||||
$select.append($('<option>').attr('value', layoutFile).text(layoutName))
|
||||
})
|
||||
$select.trigger('pause.oc.changeMonitor')
|
||||
$select.val(value)
|
||||
$select.trigger('change')
|
||||
$select.trigger('resume.oc.changeMonitor')
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
$.wn.cmsPage = new CmsPage();
|
||||
}(window.jQuery);
|
||||
260
modules/cms/assets/js/winter.dragcomponents.js
Normal file
260
modules/cms/assets/js/winter.dragcomponents.js
Normal file
@@ -0,0 +1,260 @@
|
||||
/*
|
||||
* DragComponents plugin
|
||||
*
|
||||
* Data attributes:
|
||||
* - data-control="dragcomponents" - enables the plugin on an element
|
||||
* - data-option="value" - an option with a value
|
||||
*
|
||||
* JavaScript API:
|
||||
* $('a#someElement').dragComponents({ option: 'value' })
|
||||
*
|
||||
* Dependences:
|
||||
* - Some other plugin (filename.js)
|
||||
*/
|
||||
|
||||
+function ($) { "use strict";
|
||||
|
||||
// DRAGCOMPONENTS CLASS DEFINITION
|
||||
// ============================
|
||||
|
||||
var DragComponents = function(element, options) {
|
||||
var self = this
|
||||
this.options = options
|
||||
this.$el = $(element)
|
||||
|
||||
var $el = this.$el,
|
||||
widgets = Snowboard['backend.ui.widgetHandler'](),
|
||||
$clone,
|
||||
$editorArea,
|
||||
$editor,
|
||||
$componentList,
|
||||
adjX = 0,
|
||||
adjY = 0,
|
||||
dragging = false,
|
||||
startPos,
|
||||
editorPos
|
||||
|
||||
$el.mousedown(function(event){
|
||||
if ($el.data('component-attached')) return
|
||||
|
||||
startDrag(event)
|
||||
return false
|
||||
})
|
||||
|
||||
$el.on('touchstart', function(event){
|
||||
if ($el.data('component-attached')) return
|
||||
|
||||
var touchEvent = event.originalEvent;
|
||||
if (touchEvent.touches.length == 1) {
|
||||
startDrag(touchEvent.touches[0])
|
||||
event.stopPropagation()
|
||||
}
|
||||
})
|
||||
|
||||
function initDrag(event) {
|
||||
$componentList = $('#cms-master-tabs > div.tab-content > .tab-pane.active .control-componentlist')
|
||||
$el.addClass(self.options.placeholderClass)
|
||||
$clone.show()
|
||||
$editorArea = $('#cms-master-tabs > div.tab-content > .tab-pane.active [data-control="codeeditor"]')
|
||||
if (!$editorArea.length) {
|
||||
return
|
||||
}
|
||||
|
||||
$editor = widgets.getWidget($editorArea.get(0));
|
||||
if (!$editor || !$editor.getEditor()) return;
|
||||
$editor.getEditor().focus();
|
||||
editorPos = $editor.getEditor().onMouseMove((event) => {
|
||||
if (event.target && event.target.position) {
|
||||
$editor.getEditor().setPosition(event.target.position);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
* Internal event, drag has started
|
||||
*/
|
||||
function startDrag(event) {
|
||||
|
||||
startPos = $el.offset()
|
||||
$clone = $el.clone().appendTo($(document.body))
|
||||
|
||||
$clone
|
||||
.css({
|
||||
zIndex: '99999',
|
||||
position: 'absolute',
|
||||
pointerEvents: 'none'
|
||||
})
|
||||
.addClass('draggable-component-item')
|
||||
.width($el.width())
|
||||
.height($el.height())
|
||||
.hide()
|
||||
|
||||
var objX = (event.pageX - startPos.left),
|
||||
objY = (event.pageY - startPos.top)
|
||||
|
||||
$clone.data('dragComponents', { x: objX, y: objY })
|
||||
|
||||
if (Modernizr.touchevents) {
|
||||
$(window).on('touchmove.oc.dragcomponents', function(event){
|
||||
var touchEvent = event.originalEvent
|
||||
moveDrag(touchEvent.touches[0])
|
||||
event.preventDefault()
|
||||
})
|
||||
|
||||
$(window).on('touchend.oc.dragcomponents', function(event) {
|
||||
stopDrag()
|
||||
})
|
||||
}
|
||||
else {
|
||||
$(window).on('mousemove.oc.dragcomponents', function(event){
|
||||
moveDrag(event)
|
||||
$(document.body).addClass(self.options.dragClass)
|
||||
return false
|
||||
})
|
||||
|
||||
$(window).on('mouseup.oc.dragcomponents', function(mouseUpEvent){
|
||||
var isClick = event.pageX == mouseUpEvent.pageX && event.pageY == mouseUpEvent.pageY
|
||||
stopDrag(isClick)
|
||||
return false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Internal event, drag is active
|
||||
*/
|
||||
function moveDrag(event) {
|
||||
if (!dragging) {
|
||||
dragging = true
|
||||
initDrag(event)
|
||||
}
|
||||
|
||||
adjY = $(window).scrollTop()
|
||||
adjX = $(window).scrollLeft()
|
||||
var offset = $clone.data('dragComponents')
|
||||
$clone.css({
|
||||
left: (event.pageX - adjX - offset.x),
|
||||
top: (event.pageY - adjY - offset.y)
|
||||
})
|
||||
|
||||
if (collision($clone, $componentList))
|
||||
$componentList.addClass('droppable')
|
||||
else
|
||||
$componentList.removeClass('droppable')
|
||||
}
|
||||
|
||||
/*
|
||||
* Internal event, drag has ended
|
||||
*/
|
||||
function stopDrag(click) {
|
||||
dragging = false
|
||||
|
||||
if (click)
|
||||
$(document.body).removeClass(self.options.dragClass)
|
||||
|
||||
$el.removeClass(self.options.placeholderClass)
|
||||
$(window)
|
||||
.off('mousemove.oc.dragcomponents mouseup.oc.dragcomponents')
|
||||
.removeData('dragComponents')
|
||||
|
||||
if (!click)
|
||||
finishDrag()
|
||||
|
||||
$clone.remove()
|
||||
|
||||
window.setTimeout(function(){
|
||||
if (!click) {
|
||||
$(document.body).removeClass(self.options.dragClass)
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
|
||||
function finishDrag() {
|
||||
// Dragged to the code editor
|
||||
if (collision($clone, $editorArea)) {
|
||||
// Add the component to the page
|
||||
$el.click()
|
||||
|
||||
// Can only attach to page or layouts
|
||||
if ($componentList.length && $editor && $editor.getEditor()) {
|
||||
// Inject {% component %} tag
|
||||
var alias = $('input[name="component_aliases[]"]', $el).val()
|
||||
$editor.insert("{% component '" + alias + "' %}")
|
||||
}
|
||||
}
|
||||
// Dragged to the component list
|
||||
else if (collision($clone, $componentList)) {
|
||||
// Add the component to the page
|
||||
$el.click()
|
||||
}
|
||||
|
||||
if (editorPos) {
|
||||
editorPos.dispose();
|
||||
}
|
||||
|
||||
if ($componentList.length) {
|
||||
$componentList.removeClass('droppable')
|
||||
}
|
||||
}
|
||||
|
||||
function collision($div1, $div2) {
|
||||
if (!$div1 || !$div2 || !$div1.length || !$div2.length)
|
||||
return false
|
||||
|
||||
var x1 = $div1.offset().left,
|
||||
y1 = $div1.offset().top,
|
||||
h1 = $div1.outerHeight(true),
|
||||
w1 = $div1.outerWidth(true),
|
||||
b1 = y1 + h1,
|
||||
r1 = x1 + w1,
|
||||
x2 = $div2.offset().left,
|
||||
y2 = $div2.offset().top,
|
||||
h2 = $div2.outerHeight(true),
|
||||
w2 = $div2.outerWidth(true),
|
||||
b2 = y2 + h2,
|
||||
r2 = x2 + w2
|
||||
|
||||
return !(b1 < y2 || y1 > b2 || r1 < x2 || x1 > r2)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
DragComponents.DEFAULTS = {
|
||||
dragClass: 'drag',
|
||||
placeholderClass: 'placeholder'
|
||||
}
|
||||
|
||||
// DRAGCOMPONENTS PLUGIN DEFINITION
|
||||
// ============================
|
||||
|
||||
var old = $.fn.dragComponents
|
||||
|
||||
$.fn.dragComponents = function (option) {
|
||||
var args = Array.prototype.slice.call(arguments, 1)
|
||||
return this.each(function () {
|
||||
var $this = $(this)
|
||||
var data = $this.data('oc.dragcomponents')
|
||||
var options = $.extend({}, DragComponents.DEFAULTS, $this.data(), typeof option == 'object' && option)
|
||||
if (!data) $this.data('oc.dragcomponents', (data = new DragComponents(this, options)))
|
||||
else if (typeof option == 'string') data[option].apply(data, args)
|
||||
})
|
||||
}
|
||||
|
||||
$.fn.dragComponents.Constructor = DragComponents
|
||||
|
||||
// DRAGCOMPONENTS NO CONFLICT
|
||||
// =================
|
||||
|
||||
$.fn.dragComponents.noConflict = function () {
|
||||
$.fn.dragComponents = old
|
||||
return this
|
||||
}
|
||||
|
||||
// DRAGCOMPONENTS DATA-API
|
||||
// ===============
|
||||
|
||||
$(document).on('mouseenter.oc.dragcomponents', '[data-control="dragcomponent"]', function() {
|
||||
$(this).dragComponents()
|
||||
});
|
||||
|
||||
}(window.jQuery);
|
||||
183
modules/cms/assets/js/winter.tokenexpander.js
Normal file
183
modules/cms/assets/js/winter.tokenexpander.js
Normal file
@@ -0,0 +1,183 @@
|
||||
/*
|
||||
* Token Expander plugin
|
||||
* Locates Twig tokens and replaces them with potential content inside.
|
||||
*
|
||||
* JavaScript API:
|
||||
* $('#codeEditor').tokenExpander({ option: 'value' })
|
||||
*
|
||||
* Dependences:
|
||||
* - Code Edtior (codeeditor.js)
|
||||
*/
|
||||
|
||||
+function ($) { "use strict";
|
||||
|
||||
// TOKEN EXPANDER CLASS DEFINITION
|
||||
// ============================
|
||||
|
||||
var TokenExpander = function(element, options) {
|
||||
this.options = options
|
||||
this.$el = $(element)
|
||||
|
||||
// Public properties
|
||||
this.something = false
|
||||
|
||||
// Init
|
||||
this.init()
|
||||
}
|
||||
|
||||
TokenExpander.DEFAULTS = {
|
||||
option: 'default'
|
||||
}
|
||||
|
||||
TokenExpander.prototype.init = function() {
|
||||
|
||||
this.$editor = this.$el.codeEditor('getEditorObject')
|
||||
this.$selection = this.$editor.getSelection()
|
||||
this.$session = this.$editor.getSession()
|
||||
this.tokenName = null
|
||||
this.tokenValue = null
|
||||
this.tokenDefinition = null
|
||||
this.tokenRange = null
|
||||
|
||||
this.$selection.on('changeCursor', $.proxy(this.cursorChange, this))
|
||||
}
|
||||
|
||||
TokenExpander.prototype.cursorChange = function(event) {
|
||||
|
||||
var cursor = this.$selection.getCursor(),
|
||||
word = this.getActiveWord(cursor).toLowerCase()
|
||||
|
||||
if (word == 'component') {
|
||||
this.handleCursorOnToken(cursor, word)
|
||||
}
|
||||
else if (this.tokenName) {
|
||||
this.tokenName = null
|
||||
this.tokenValue = null
|
||||
this.tokenDefinition = null
|
||||
this.tokenRange = null
|
||||
|
||||
this.$el.trigger('hide.oc.tokenexpander')
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
TokenExpander.prototype.handleCursorOnToken = function(cursor, token) {
|
||||
var line = this.$session.getLine(cursor.row),
|
||||
definition = this.getTwigTokenDefinition(token, line, cursor.column)
|
||||
|
||||
if (definition) {
|
||||
|
||||
var value = this.getTwigTokenValue(token, definition[0])
|
||||
|
||||
if (value) {
|
||||
if (!this.tokenName)
|
||||
this.$el.trigger('show.oc.tokenexpander')
|
||||
|
||||
this.tokenName = token
|
||||
this.tokenValue = value
|
||||
this.tokenDefinition = definition
|
||||
this.tokenRange = this.$selection.getRange() // Used only for its row
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback must return a promise object
|
||||
*/
|
||||
TokenExpander.prototype.expandToken = function(callback) {
|
||||
var $editor = this.$editor,
|
||||
$session = this.$session,
|
||||
definition = this.tokenDefinition,
|
||||
range = this.tokenRange
|
||||
|
||||
$editor.setReadOnly(true)
|
||||
|
||||
callback(this.tokenName, this.tokenValue)
|
||||
.done(function(data){
|
||||
range.setStart(range.start.row, definition[1])
|
||||
range.setEnd(range.end.row, definition[2])
|
||||
$session.replace(range, data.result)
|
||||
})
|
||||
.always(function(){
|
||||
$editor.setReadOnly(false)
|
||||
})
|
||||
}
|
||||
|
||||
TokenExpander.prototype.getTwigTokenValue = function(tokenName, tokenString) {
|
||||
|
||||
var regex = new RegExp("^{%\\s*"+tokenName+"\\s(['"+'"'+"])([^"+'"'+"']+)(?:\\1)[^(?:%})]+%}$", "i"),
|
||||
regexMatch = regex.exec(tokenString)
|
||||
|
||||
if (regexMatch && regexMatch[2])
|
||||
return regexMatch[2]
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array of [tokenString, startPos, endPos] or null
|
||||
* Eg: ['{% component "thing" %}', 0, 23]
|
||||
*/
|
||||
TokenExpander.prototype.getTwigTokenDefinition = function(token, str, pos, filter) {
|
||||
|
||||
if (!filter)
|
||||
filter = 0
|
||||
|
||||
var filteredStr = str.substring(filter),
|
||||
regex = new RegExp("{%\\s*"+token+"\\s[^(?:%})]+%}", "i"),
|
||||
regexMatch = regex.exec(filteredStr)
|
||||
|
||||
if (regexMatch) {
|
||||
var start = str.indexOf(regexMatch[0], filter),
|
||||
end = start + regexMatch[0].length
|
||||
|
||||
// Win!
|
||||
if (start < pos && end > pos)
|
||||
return [regexMatch[0], start, end]
|
||||
|
||||
// Try again
|
||||
return this.getTwigTokenDefinition(token, str, pos, end)
|
||||
}
|
||||
|
||||
// Fail
|
||||
return null
|
||||
}
|
||||
|
||||
TokenExpander.prototype.getActiveWord = function(cursor) {
|
||||
var $session = this.$session,
|
||||
wordRange = $session.getWordRange(cursor.row, cursor.column),
|
||||
word = $session.getTextRange(wordRange)
|
||||
|
||||
return word
|
||||
}
|
||||
|
||||
// TOKEN EXPANDER PLUGIN DEFINITION
|
||||
// ============================
|
||||
|
||||
var old = $.fn.tokenExpander
|
||||
|
||||
$.fn.tokenExpander = function (option) {
|
||||
var args = Array.prototype.slice.call(arguments, 1), regexMatch
|
||||
this.each(function () {
|
||||
var $this = $(this)
|
||||
var data = $this.data('oc.tokenexpander')
|
||||
var options = $.extend({}, TokenExpander.DEFAULTS, $this.data(), typeof option == 'object' && option)
|
||||
if (!data) $this.data('oc.tokenexpander', (data = new TokenExpander(this, options)))
|
||||
if (typeof option == 'string') regexMatch = data[option].apply(data, args)
|
||||
if (typeof regexMatch != 'undefined') return false
|
||||
})
|
||||
|
||||
return regexMatch ? regexMatch : this
|
||||
}
|
||||
|
||||
$.fn.tokenExpander.Constructor = TokenExpander
|
||||
|
||||
// TOKEN EXPANDER NO CONFLICT
|
||||
// =================
|
||||
|
||||
$.fn.tokenExpander.noConflict = function () {
|
||||
$.fn.tokenExpander = old
|
||||
return this
|
||||
}
|
||||
|
||||
}(window.jQuery);
|
||||
406
modules/cms/assets/less/winter.components.less
Normal file
406
modules/cms/assets/less/winter.components.less
Normal file
@@ -0,0 +1,406 @@
|
||||
@import "../../../backend/assets/less/core/boot.less";
|
||||
|
||||
//
|
||||
// Component list
|
||||
// --------------------------------------------------
|
||||
|
||||
@color-component-list-bg: @brand-secondary;
|
||||
@color-component-bg: #ffffff;
|
||||
@color-component-text: #475354;
|
||||
@color-component-hover-bg: @brand-accent;
|
||||
@color-component-hover-text: #ffffff;
|
||||
@color-component-placeholder: #e0e0e0;
|
||||
@color-group-bg: #f1f3f4;
|
||||
@color-error-component-bg: @brand-danger;
|
||||
@color-error-component-text: #ffffff;
|
||||
@color-warning-component-bg: #ffc107;
|
||||
@color-warning-component-text: #343a40;
|
||||
|
||||
.component-lego-icon() {
|
||||
position: absolute;
|
||||
font-size: 37px;
|
||||
top: 1px;
|
||||
z-index: 50;
|
||||
color: @color-component-bg;
|
||||
text-shadow: 0 0 1px @color-component-text;
|
||||
width: 12px;
|
||||
overflow: hidden;
|
||||
text-indent: -25px;
|
||||
right: -12px;
|
||||
|
||||
.icon(@puzzle-piece);
|
||||
}
|
||||
|
||||
.draggable-component-item,
|
||||
.component-list .components div.layout-cell,
|
||||
div.control-componentlist div.components div.layout-cell {
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
background: @color-component-bg;
|
||||
.user-select(none);
|
||||
|
||||
&:hover {
|
||||
background: @color-component-hover-bg;
|
||||
}
|
||||
|
||||
> div {
|
||||
white-space: normal;
|
||||
color: @color-component-text;
|
||||
position: relative;
|
||||
border-right: 1px solid @color-panel-light;
|
||||
|
||||
&:before {
|
||||
position: absolute;
|
||||
font-size: 16px;
|
||||
left: 15px;
|
||||
top: 7px;
|
||||
.opacity(0.7);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: @color-component-hover-text;
|
||||
&:before {
|
||||
.opacity(1);
|
||||
}
|
||||
}
|
||||
|
||||
&:after {
|
||||
.component-lego-icon();
|
||||
}
|
||||
|
||||
&:hover:after {
|
||||
text-shadow: none;
|
||||
color: @color-component-hover-bg;
|
||||
}
|
||||
|
||||
span {
|
||||
display: block;
|
||||
|
||||
&.name {
|
||||
white-space: nowrap;
|
||||
padding: 8px 15px 0;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
font-size: @font-size-base - 1;
|
||||
}
|
||||
|
||||
&.description {
|
||||
padding: 0 15px 10px;
|
||||
margin-top: 8px;
|
||||
font-weight: 400;
|
||||
font-size: @font-size-base - 3;
|
||||
line-height: 150%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.placeholder > div {
|
||||
background: @color-component-placeholder;
|
||||
color: @color-component-placeholder;
|
||||
&:before, &:after {
|
||||
color: @color-component-placeholder !important;
|
||||
text-shadow: none !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reduce the double standard padding, since the tabs have padding
|
||||
// at the top and the components field has padding at the bottom.
|
||||
[data-field-name="components"] + .control-tabs.primary-tabs {
|
||||
margin-top: -(@padding-standard / 2);
|
||||
}
|
||||
|
||||
div.control-componentlist {
|
||||
position: relative;
|
||||
padding: 0;
|
||||
.transition(all 0.3s ease);
|
||||
|
||||
&.droppable {
|
||||
background-color: lighten(@color-component-list-bg, 20%);
|
||||
}
|
||||
|
||||
&.has-components {
|
||||
padding: 0 @padding-standard @padding-standard;
|
||||
}
|
||||
|
||||
div.layout {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
div.components {
|
||||
div.layout-cell {
|
||||
&.error-component {
|
||||
background: @color-error-component-bg;
|
||||
|
||||
> div {
|
||||
color: @color-error-component-text;
|
||||
|
||||
&:after {
|
||||
color: @color-error-component-bg;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.warning-component {
|
||||
background: @color-warning-component-bg;
|
||||
|
||||
> div {
|
||||
color: @color-warning-component-text;
|
||||
|
||||
&:after {
|
||||
color: @color-warning-component-bg;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
.border-left-radius(3px);
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-right: 0;
|
||||
.border-right-radius(3px);
|
||||
|
||||
> div {
|
||||
border-right: none;
|
||||
|
||||
&:after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:nth-child(2n) > div:after {
|
||||
top: auto;
|
||||
bottom: 5px;
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
> div.popover-highlight {
|
||||
.border-left-radius(3px);
|
||||
}
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
> div.popover-highlight {
|
||||
.border-right-radius(3px);
|
||||
}
|
||||
}
|
||||
|
||||
> div {
|
||||
.translate(0, 0);
|
||||
.transition-transform(0.2s);
|
||||
|
||||
max-width: 250px;
|
||||
min-width: 170px;
|
||||
|
||||
&.popover-highlight {
|
||||
border-right-color: rgba(0,0,0,0);
|
||||
background: @color-component-bg!important;
|
||||
color: @color-component-text!important;
|
||||
&:before {
|
||||
.opacity(0.7);
|
||||
}
|
||||
|
||||
&:after {
|
||||
color: @color-component-bg;
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
a.remove {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
&.name {
|
||||
padding-left: 38px;
|
||||
}
|
||||
|
||||
&.description {
|
||||
padding-bottom: 35px;
|
||||
}
|
||||
|
||||
&.alias {
|
||||
padding: 0 15px;
|
||||
font-weight: 500;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
bottom: 10px;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
|
||||
&:before {
|
||||
margin-right: 4px;
|
||||
.opacity(0.55);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
a.remove {
|
||||
position: absolute;
|
||||
display: inline-block;
|
||||
top: 1px;
|
||||
right: 5px;
|
||||
color: @close-color;
|
||||
font-size: 17px;
|
||||
font-weight: @close-font-weight;
|
||||
.opacity(.3);
|
||||
|
||||
&:hover {
|
||||
.opacity(.5);
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.adding > div {
|
||||
.translate(0, -100px)!important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.draggable-component-item {
|
||||
.opacity(.6);
|
||||
span.alias { display: none; }
|
||||
a.remove { display: none; }
|
||||
}
|
||||
|
||||
.component-list .components {
|
||||
div.layout {
|
||||
div.layout-row {
|
||||
div.layout-cell {
|
||||
border-top: 1px solid @color-panel-light;
|
||||
span.alias { display: none; }
|
||||
a.remove { display: none; }
|
||||
|
||||
&:last-child > div {
|
||||
border-right: none;
|
||||
&:after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
> div {
|
||||
&:before {
|
||||
.component-lego-icon();
|
||||
.rotate(-90deg);
|
||||
.opacity(1);
|
||||
left: auto;
|
||||
top: -17px;
|
||||
right: 15px;
|
||||
}
|
||||
|
||||
&:hover:before {
|
||||
text-shadow: none;
|
||||
color: @color-component-hover-bg;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
div.layout-cell {
|
||||
> div {
|
||||
&:before {display: none;}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
div.layout.single {
|
||||
div.layout-row > div.layout-cell > div {
|
||||
border-right: none;
|
||||
&:before {
|
||||
display: block;
|
||||
right: 55.5%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.control-filelist.component-list {
|
||||
ul li {
|
||||
div.group {
|
||||
background: @color-group-bg;
|
||||
border-top: 1px solid darken(@color-panel-light, 2%);
|
||||
padding: 10px 15px 10px 10px;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
|
||||
h4 {
|
||||
text-transform: uppercase;
|
||||
font-size: 14px;
|
||||
margin-top: 3px;
|
||||
|
||||
a {
|
||||
padding-left: 33px;
|
||||
|
||||
&:before {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
&:after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
span.description {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
padding-left: 33px;
|
||||
color: @color-text-description;
|
||||
}
|
||||
|
||||
i {
|
||||
position: absolute;
|
||||
left: 22px;
|
||||
top: 21px;
|
||||
font-size: 16px;
|
||||
.opacity(0.7);
|
||||
color: @color-text-title;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.disable-component-list-hover() {
|
||||
background: @color-component-bg;
|
||||
color: @color-component-text;
|
||||
|
||||
&:after {
|
||||
text-shadow: 0 0 1px @color-component-text!important;
|
||||
color: @color-component-bg!important;
|
||||
}
|
||||
}
|
||||
|
||||
.touch {
|
||||
div.control-componentlist div.components div.layout-cell {
|
||||
> div:hover, > div:active, > div:active:focus {
|
||||
.disable-component-list-hover();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Modifies the color of the tab background when components are present
|
||||
// this is no longer required since the colors are the same! :-) -sg
|
||||
// .fancy-layout {
|
||||
// .control-tabs, &.control-tabs {
|
||||
// &.primary-tabs {
|
||||
// > div > ul.nav-tabs {
|
||||
// &.component-area {
|
||||
// background: @color-component-list-bg;
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
body.drag div.control-componentlist div.components div.layout-cell {
|
||||
> div, > div:hover, > div:active {
|
||||
.disable-component-list-hover();
|
||||
}
|
||||
}
|
||||
160
modules/cms/assets/less/winter.theme-selector.less
Normal file
160
modules/cms/assets/less/winter.theme-selector.less
Normal file
@@ -0,0 +1,160 @@
|
||||
@import "../../../backend/assets/less/core/boot.less";
|
||||
|
||||
.theme-selector-layout {
|
||||
.layout-cell {
|
||||
padding: 24px;
|
||||
.box-sizing(border-box);
|
||||
}
|
||||
|
||||
.theme-thumbnail {
|
||||
width: 288px;
|
||||
background: #ecf0f1;
|
||||
border-top: 1px solid #e3e7e9;
|
||||
|
||||
img {
|
||||
.opacity(0.6);
|
||||
width: 240px;
|
||||
}
|
||||
}
|
||||
|
||||
.theme-description {
|
||||
border-top: 1px solid #f2f3f4;
|
||||
|
||||
h3, p {
|
||||
.opacity(0.6);
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0 0 25px 0;
|
||||
font-size: 28px;
|
||||
color: #2b3e50;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
p.author {
|
||||
font-size: 13px;
|
||||
display: inline-block;
|
||||
color: #808c8d;
|
||||
}
|
||||
|
||||
p.description {
|
||||
color: #2b3e50;
|
||||
font-size: 14px;
|
||||
line-height: 180%;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.controls {
|
||||
.btn > i {
|
||||
margin-right: 5px;
|
||||
font-size: 16px;
|
||||
position: relative;
|
||||
top: 1px;
|
||||
|
||||
&.icon-star {
|
||||
color: #f1a84e;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.layout-row.active {
|
||||
.theme-thumbnail {
|
||||
background: #bdc3c7;
|
||||
border-top-color: #bdc3c7;
|
||||
}
|
||||
|
||||
.thumbnail-container {
|
||||
position: relative;
|
||||
|
||||
&:after {
|
||||
.triangle(right, 15px, 28px, #bdc3c7);
|
||||
position: absolute;
|
||||
right: -35px;
|
||||
top: 50%;
|
||||
margin-top: -14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.layout-row {
|
||||
&.active, &:hover {
|
||||
.theme-description {
|
||||
h3, p {
|
||||
.opacity(1);
|
||||
}
|
||||
}
|
||||
|
||||
.theme-thumbnail {
|
||||
img {
|
||||
.opacity(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:first-child, &.links {
|
||||
.theme-description, .theme-thumbnail {
|
||||
border-top: none;
|
||||
}
|
||||
}
|
||||
|
||||
&.links {
|
||||
.theme-thumbnail {
|
||||
border-bottom: 1px solid #e3e7e9;
|
||||
}
|
||||
.theme-description {
|
||||
border-bottom: 1px solid #f2f3f4;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.create-new-theme {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.create-new-theme,
|
||||
.find-more-themes {
|
||||
background: #ecf0f1;
|
||||
color: #2b3e50;
|
||||
text-decoration: none;
|
||||
display: block;
|
||||
padding: 20px;
|
||||
.border-radius(4px);
|
||||
|
||||
&:hover {
|
||||
background: @link-color;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Screen specific
|
||||
//
|
||||
|
||||
@media (max-width: @screen-sm) {
|
||||
.theme-selector-layout {
|
||||
.layout-cell, .layout-row {
|
||||
display: block!important;
|
||||
width: auto!important;
|
||||
height: auto!important;
|
||||
}
|
||||
|
||||
.theme-thumbnail {
|
||||
img {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.layout-row.links {
|
||||
.theme-thumbnail {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
1407
modules/cms/assets/vendor/jsdiff/diff.js
vendored
Normal file
1407
modules/cms/assets/vendor/jsdiff/diff.js
vendored
Normal file
File diff suppressed because one or more lines are too long
313
modules/cms/classes/Asset.php
Normal file
313
modules/cms/classes/Asset.php
Normal file
@@ -0,0 +1,313 @@
|
||||
<?php namespace Cms\Classes;
|
||||
|
||||
use File;
|
||||
use Lang;
|
||||
use Config;
|
||||
use Request;
|
||||
use ApplicationException;
|
||||
use ValidationException;
|
||||
use Cms\Helpers\File as FileHelper;
|
||||
use Winter\Storm\Extension\Extendable;
|
||||
use Winter\Storm\Filesystem\PathResolver;
|
||||
|
||||
/**
|
||||
* The CMS theme asset file class.
|
||||
*
|
||||
* @package winter\wn-cms-module
|
||||
* @author Alexey Bobkov, Samuel Georges
|
||||
*/
|
||||
class Asset extends Extendable
|
||||
{
|
||||
/**
|
||||
* @var \Cms\Classes\Theme A reference to the CMS theme containing the object.
|
||||
*/
|
||||
protected $theme;
|
||||
|
||||
/**
|
||||
* @var string The container name inside the theme.
|
||||
*/
|
||||
protected $dirName = 'assets';
|
||||
|
||||
/**
|
||||
* @var string Specifies the file name corresponding the CMS object.
|
||||
*/
|
||||
public $fileName;
|
||||
|
||||
/**
|
||||
* @var string Specifies the file name, the CMS object was loaded from.
|
||||
*/
|
||||
protected $originalFileName;
|
||||
|
||||
/**
|
||||
* @var string Last modified time.
|
||||
*/
|
||||
public $mtime;
|
||||
|
||||
/**
|
||||
* @var string The entire file content.
|
||||
*/
|
||||
public $content;
|
||||
|
||||
/**
|
||||
* @var array The attributes that are mass assignable.
|
||||
*/
|
||||
protected $fillable = [
|
||||
'fileName',
|
||||
'content'
|
||||
];
|
||||
|
||||
/**
|
||||
* @var array Allowable file extensions.
|
||||
*/
|
||||
protected $allowedExtensions = [];
|
||||
|
||||
/**
|
||||
* @var bool Indicates if the model exists.
|
||||
*/
|
||||
public $exists = false;
|
||||
|
||||
/**
|
||||
* Creates an instance of the object and associates it with a CMS theme.
|
||||
* @param \Cms\Classes\Theme $theme Specifies the theme the object belongs to.
|
||||
*/
|
||||
public function __construct(Theme $theme)
|
||||
{
|
||||
$this->theme = $theme;
|
||||
|
||||
$this->allowedExtensions = self::getEditableExtensions();
|
||||
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the object from a file.
|
||||
* This method is used in the CMS back-end. It doesn't use any caching.
|
||||
* @param \Cms\Classes\Theme $theme Specifies the theme the object belongs to.
|
||||
* @param string $fileName Specifies the file name, with the extension.
|
||||
* The file name can contain only alphanumeric symbols, dashes and dots.
|
||||
* @return mixed Returns a CMS object instance or null if the object wasn't found.
|
||||
*/
|
||||
public static function load($theme, $fileName)
|
||||
{
|
||||
return (new static($theme))->find($fileName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares the theme datasource for the model.
|
||||
* @param \Cms\Classes\Theme|string $theme Specifies a parent theme.
|
||||
* @return $this
|
||||
*/
|
||||
public static function inTheme($theme)
|
||||
{
|
||||
if (is_string($theme)) {
|
||||
$theme = Theme::load($theme);
|
||||
}
|
||||
|
||||
return new static($theme);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a single template by its file name.
|
||||
*/
|
||||
public function find(string $fileName): ?static
|
||||
{
|
||||
$filePath = $this->getFilePath($fileName);
|
||||
|
||||
if (!File::isFile($filePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (($content = @File::get($filePath)) === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->fileName = $fileName;
|
||||
$this->originalFileName = $fileName;
|
||||
$this->mtime = File::lastModified($filePath);
|
||||
$this->content = $content;
|
||||
$this->exists = true;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the object attributes.
|
||||
* @param array $attributes A list of attributes to set.
|
||||
*/
|
||||
public function fill(array $attributes)
|
||||
{
|
||||
foreach ($attributes as $key => $value) {
|
||||
if (!in_array($key, $this->fillable)) {
|
||||
throw new ApplicationException(Lang::get(
|
||||
'cms::lang.cms_object.invalid_property',
|
||||
['name' => $key]
|
||||
));
|
||||
}
|
||||
|
||||
$this->$key = $value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the object to the disk.
|
||||
*/
|
||||
public function save()
|
||||
{
|
||||
$this->validateFileName();
|
||||
|
||||
$fullPath = $this->getFilePath();
|
||||
|
||||
if (File::isFile($fullPath) && $this->originalFileName !== $this->fileName) {
|
||||
throw new ApplicationException(Lang::get(
|
||||
'cms::lang.cms_object.file_already_exists',
|
||||
['name'=>$this->fileName]
|
||||
));
|
||||
}
|
||||
|
||||
$dirPath = $this->theme->getPath().'/'.$this->dirName;
|
||||
if (!file_exists($dirPath) || !is_dir($dirPath)) {
|
||||
if (!File::makeDirectory($dirPath, 0777, true, true)) {
|
||||
throw new ApplicationException(Lang::get(
|
||||
'cms::lang.cms_object.error_creating_directory',
|
||||
['name'=>$dirPath]
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if (($pos = strpos($this->fileName, '/')) !== false) {
|
||||
$dirPath = dirname($fullPath);
|
||||
|
||||
if (!is_dir($dirPath) && !File::makeDirectory($dirPath, 0777, true, true)) {
|
||||
throw new ApplicationException(Lang::get(
|
||||
'cms::lang.cms_object.error_creating_directory',
|
||||
['name'=>$dirPath]
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
$newFullPath = $fullPath;
|
||||
if (@File::put($fullPath, $this->content) === false) {
|
||||
throw new ApplicationException(Lang::get(
|
||||
'cms::lang.cms_object.error_saving',
|
||||
['name'=>$this->fileName]
|
||||
));
|
||||
}
|
||||
|
||||
if (strlen($this->originalFileName) && $this->originalFileName !== $this->fileName) {
|
||||
$fullPath = $this->getFilePath($this->originalFileName);
|
||||
|
||||
if (File::isFile($fullPath)) {
|
||||
@unlink($fullPath);
|
||||
}
|
||||
}
|
||||
|
||||
clearstatcache();
|
||||
|
||||
$this->mtime = @File::lastModified($newFullPath);
|
||||
$this->originalFileName = $this->fileName;
|
||||
$this->exists = true;
|
||||
}
|
||||
|
||||
public function delete()
|
||||
{
|
||||
$fileName = Request::input('fileName');
|
||||
$fullPath = $this->getFilePath($fileName);
|
||||
|
||||
$this->validateFileName($fileName);
|
||||
|
||||
if (File::exists($fullPath)) {
|
||||
if (!@File::delete($fullPath)) {
|
||||
throw new ApplicationException(Lang::get(
|
||||
'cms::lang.asset.error_deleting_file',
|
||||
['name' => $fileName]
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the supplied filename, extension and path.
|
||||
* @param string $fileName
|
||||
*/
|
||||
protected function validateFileName($fileName = null)
|
||||
{
|
||||
if ($fileName === null) {
|
||||
$fileName = $this->fileName;
|
||||
}
|
||||
|
||||
$fileName = trim($fileName);
|
||||
|
||||
if (!strlen($fileName)) {
|
||||
throw new ValidationException(['fileName' =>
|
||||
Lang::get('cms::lang.cms_object.file_name_required', [
|
||||
'allowed' => implode(', ', $this->allowedExtensions),
|
||||
'invalid' => pathinfo($fileName, PATHINFO_EXTENSION)
|
||||
])
|
||||
]);
|
||||
}
|
||||
|
||||
if (!FileHelper::validateExtension($fileName, $this->allowedExtensions, false)) {
|
||||
throw new ValidationException(['fileName' =>
|
||||
Lang::get('cms::lang.cms_object.invalid_file_extension', [
|
||||
'allowed' => implode(', ', $this->allowedExtensions),
|
||||
'invalid' => pathinfo($fileName, PATHINFO_EXTENSION)
|
||||
])
|
||||
]);
|
||||
}
|
||||
|
||||
if (!FileHelper::validatePath($fileName, null)) {
|
||||
throw new ValidationException(['fileName' =>
|
||||
Lang::get('cms::lang.cms_object.invalid_file', [
|
||||
'name' => $fileName
|
||||
])
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the file name.
|
||||
* @return string
|
||||
*/
|
||||
public function getFileName()
|
||||
{
|
||||
return $this->fileName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the absolute file path.
|
||||
* @param string $fileName Specifies the file name to return the path to.
|
||||
* @return string
|
||||
*/
|
||||
public function getFilePath($fileName = null)
|
||||
{
|
||||
if ($fileName === null) {
|
||||
$fileName = $this->fileName;
|
||||
}
|
||||
|
||||
$directory = $this->theme->getPath() . '/' . $this->dirName . '/';
|
||||
$filePath = $directory . $fileName;
|
||||
|
||||
// Limit paths to those under the theme's assets directory
|
||||
if (!PathResolver::within($filePath, $directory)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return PathResolver::resolve($filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of editable asset extensions.
|
||||
* The list can be overridden with the cms.editableAssetTypes configuration option.
|
||||
* @return array
|
||||
*/
|
||||
public static function getEditableExtensions()
|
||||
{
|
||||
$defaultTypes = ['css', 'js', 'less', 'sass', 'scss'];
|
||||
|
||||
$configTypes = Config::get('cms.editableAssetTypes');
|
||||
if (!$configTypes) {
|
||||
return $defaultTypes;
|
||||
}
|
||||
|
||||
return $configTypes;
|
||||
}
|
||||
}
|
||||
581
modules/cms/classes/AutoDatasource.php
Normal file
581
modules/cms/classes/AutoDatasource.php
Normal file
@@ -0,0 +1,581 @@
|
||||
<?php
|
||||
|
||||
namespace Cms\Classes;
|
||||
|
||||
use ApplicationException;
|
||||
use Cache;
|
||||
use Exception;
|
||||
use Winter\Storm\Halcyon\Datasource\Datasource;
|
||||
use Winter\Storm\Halcyon\Datasource\DatasourceInterface;
|
||||
use Winter\Storm\Halcyon\Exception\DeleteFileException;
|
||||
use Winter\Storm\Halcyon\Model;
|
||||
use Winter\Storm\Halcyon\Processors\Processor;
|
||||
use Winter\Storm\Support\Facades\Config;
|
||||
|
||||
/**
|
||||
* Datasource that loads from other data sources automatically
|
||||
*
|
||||
* @package winter\wn-cms-module
|
||||
* @author Luke Towers
|
||||
*/
|
||||
class AutoDatasource extends Datasource implements DatasourceInterface
|
||||
{
|
||||
/**
|
||||
* @var array The available datasource instances
|
||||
*/
|
||||
protected $datasources = [];
|
||||
|
||||
/**
|
||||
* @var string The cache key to use for this datasource instance
|
||||
*/
|
||||
protected $cacheKey = 'halcyon-datastore-auto';
|
||||
|
||||
/**
|
||||
* @var array Local cache of paths available in the datasources
|
||||
*/
|
||||
protected $pathCache = [];
|
||||
|
||||
/**
|
||||
* @var boolean Flag on whether the cache should respect refresh requests
|
||||
*/
|
||||
protected $allowCacheRefreshes = true;
|
||||
|
||||
/**
|
||||
* @var string The key for the datasource to perform CRUD operations on
|
||||
*/
|
||||
public $activeDatasourceKey = '';
|
||||
|
||||
/**
|
||||
* @var bool Flag to indicate that we're in "single datasource mode"
|
||||
*/
|
||||
protected $singleDatasourceMode = false;
|
||||
|
||||
/**
|
||||
* Create a new datasource instance.
|
||||
*
|
||||
* @param array $datasources Array of datasources to utilize. Lower indexes = higher priority ['datasourceName' => $datasource]
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(array $datasources, ?string $cacheKey = null)
|
||||
{
|
||||
$this->datasources = $datasources;
|
||||
|
||||
if ($cacheKey) {
|
||||
$this->cacheKey = $cacheKey;
|
||||
}
|
||||
|
||||
$this->activeDatasourceKey = array_keys($datasources)[0];
|
||||
|
||||
$this->populateCache();
|
||||
|
||||
$this->postProcessor = new Processor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a datasource to the end of the list of datasources
|
||||
*/
|
||||
public function appendDatasource(string $key, DatasourceInterface $datasource): void
|
||||
{
|
||||
$this->datasources[$key] = $datasource;
|
||||
$this->pathCache[] = $this->fetchPathCache($datasource);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepend a datasource to the beginning of the list of datasources
|
||||
*/
|
||||
public function prependDatasource(string $key, DatasourceInterface $datasource): void
|
||||
{
|
||||
$this->datasources = array_prepend($this->datasources, $datasource, $key);
|
||||
$this->pathCache = array_prepend($this->pathCache, $this->fetchPathCache($datasource), $key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the in memory path cache map
|
||||
*/
|
||||
public function getPathCache(): array
|
||||
{
|
||||
return $this->pathCache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate the local cache of paths available in each datasource
|
||||
*
|
||||
* @param boolean $refresh Default false, set to true to force the cache to be rebuilt
|
||||
*/
|
||||
public function populateCache(bool $refresh = false): void
|
||||
{
|
||||
$pathCache = [];
|
||||
foreach ($this->datasources as $datasource) {
|
||||
// Allow AutoDatasource instances to handle their own internal caching
|
||||
if ($datasource instanceof AutoDatasource) {
|
||||
$datasource->populateCache($refresh);
|
||||
$pathCache[] = array_merge(...array_reverse($datasource->getPathCache()));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Remove any existing cache data
|
||||
if ($refresh && $this->allowCacheRefreshes) {
|
||||
Cache::forget($datasource->getPathsCacheKey());
|
||||
}
|
||||
|
||||
// Load the cache
|
||||
$pathCache[] = $this->fetchPathCache($datasource);
|
||||
}
|
||||
$this->pathCache = $pathCache;
|
||||
}
|
||||
|
||||
protected function fetchPathCache(DatasourceInterface $datasource): array
|
||||
{
|
||||
$pathCache = [];
|
||||
if (Config::get('app.debug', false)) {
|
||||
$pathCache = $datasource->getAvailablePaths();
|
||||
} else {
|
||||
$pathCache = Cache::rememberForever($datasource->getPathsCacheKey(), function () use ($datasource) {
|
||||
return $datasource->getAvailablePaths();
|
||||
});
|
||||
}
|
||||
return $pathCache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check to see if the specified datasource has the provided Halcyon Model
|
||||
*/
|
||||
public function sourceHasModel(string $source, Model $model): bool
|
||||
{
|
||||
if (!$model->exists) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$result = false;
|
||||
|
||||
$sourcePaths = $this->getSourcePaths($source);
|
||||
|
||||
if (!empty($sourcePaths)) {
|
||||
// Generate the path
|
||||
list($name, $extension) = $model->getFileNameParts();
|
||||
$path = $this->makeFilePath($model->getObjectTypeDirName(), $name, $extension);
|
||||
|
||||
// Deleted paths are included as being handled by a datasource
|
||||
// The functionality built on this will need to make sure they
|
||||
// include deleted records when actually performing syncing actions
|
||||
if (isset($sourcePaths[$path])) {
|
||||
$result = true;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the available paths for the specified datasource key
|
||||
*/
|
||||
public function getSourcePaths(string $source): array
|
||||
{
|
||||
$result = [];
|
||||
|
||||
$keys = array_keys($this->datasources);
|
||||
if (in_array($source, $keys)) {
|
||||
// Get the datasource's cache index key
|
||||
$cacheIndex = array_search($source, $keys);
|
||||
|
||||
// Return the available paths
|
||||
$result = $this->pathCache[$cacheIndex];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Forces all operations in a provided closure to run within a selected datasource.
|
||||
*
|
||||
* @throws ApplicationException if the provided datasource key doesn't exist
|
||||
*/
|
||||
public function usingSource(string $source, \Closure $closure): mixed
|
||||
{
|
||||
if (!array_key_exists($source, $this->datasources)) {
|
||||
throw new ApplicationException('Invalid datasource specified.');
|
||||
}
|
||||
|
||||
// Setup the datasource for single source mode
|
||||
$previousSource = $this->activeDatasourceKey;
|
||||
$this->activeDatasourceKey = $source;
|
||||
$this->singleDatasourceMode = true;
|
||||
|
||||
// Execute the callback
|
||||
$return = $closure->call($this);
|
||||
|
||||
// Restore the datasource to auto mode
|
||||
$this->singleDatasourceMode = false;
|
||||
$this->activeDatasourceKey = $previousSource;
|
||||
|
||||
return $return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Push the provided model to the specified datasource
|
||||
*/
|
||||
public function pushToSource(Model $model, string $source): void
|
||||
{
|
||||
$this->usingSource($source, function () use ($model) {
|
||||
$datasource = $this->getActiveDatasource();
|
||||
|
||||
// Get the path parts
|
||||
$dirName = $model->getObjectTypeDirName();
|
||||
list($fileName, $extension) = $model->getFileNameParts();
|
||||
|
||||
// Get the file content
|
||||
$content = $datasource->getPostProcessor()->processUpdate($model->newQuery(), []);
|
||||
|
||||
// Perform an update on the selected datasource (will insert if it doesn't exist)
|
||||
$this->update($dirName, $fileName, $extension, $content);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the provided model from the specified datasource
|
||||
*/
|
||||
public function removeFromSource(Model $model, string $source): void
|
||||
{
|
||||
$this->usingSource($source, function () use ($model) {
|
||||
$datasource = $this->getActiveDatasource();
|
||||
|
||||
// Get the path parts
|
||||
$dirName = $model->getObjectTypeDirName();
|
||||
list($fileName, $extension) = $model->getFileNameParts();
|
||||
|
||||
// Perform a forced delete on the selected datasource to ensure it's removed
|
||||
$this->forceDelete($dirName, $fileName, $extension);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the appropriate datasource for the provided path
|
||||
*/
|
||||
protected function getDatasourceForPath(string $path): DatasourceInterface
|
||||
{
|
||||
// Always return the active datasource when singleDatasourceMode is enabled
|
||||
if ($this->singleDatasourceMode) {
|
||||
return $this->getActiveDatasource();
|
||||
}
|
||||
|
||||
// Default to the last datasource provided
|
||||
$datasourceIndex = count($this->datasources) - 1;
|
||||
|
||||
$isDeleted = false;
|
||||
|
||||
foreach ($this->pathCache as $i => $paths) {
|
||||
if (isset($paths[$path])) {
|
||||
$datasourceIndex = $i;
|
||||
|
||||
// Set isDeleted to the inverse of the the path's existance flag
|
||||
$isDeleted = !$paths[$path];
|
||||
|
||||
// Break on first datasource that can handle the path
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($isDeleted) {
|
||||
throw new Exception("$path is deleted");
|
||||
}
|
||||
|
||||
$datasourceIndex = array_keys($this->datasources)[$datasourceIndex];
|
||||
|
||||
return $this->datasources[$datasourceIndex];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the path cache entry for the provided path from the first datasource that reports it
|
||||
*
|
||||
* @return mixed The datasource's entry for this path, or null if no datasource reports it.
|
||||
* Database datasources report a last modified timestamp, other datasources
|
||||
* report `true`, and paths marked as deleted report `false`.
|
||||
*/
|
||||
protected function getPathCacheEntry(string $path): mixed
|
||||
{
|
||||
foreach ($this->pathCache as $paths) {
|
||||
if (isset($paths[$path])) {
|
||||
return $paths[$path];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all valid paths for the provided directory, removing any paths marked as deleted
|
||||
*
|
||||
* @param string $dirName
|
||||
* @param array $options Array of options, [
|
||||
* 'extensions' => ['htm', 'md', 'twig'], // Extensions to search for
|
||||
* 'fileMatch' => '*gr[ae]y', // Shell matching pattern to match the filename against using the fnmatch function
|
||||
* ];
|
||||
* @return array $paths ["$dirName/path/1.md", "$dirName/path/2.md"]
|
||||
*/
|
||||
protected function getValidPaths(string $dirName, array $options = []): array
|
||||
{
|
||||
// Initialize result set
|
||||
$paths = [];
|
||||
|
||||
// Reverse the order of the sources so that earlier
|
||||
// sources are prioritized over later sources
|
||||
$pathsCache = array_reverse($this->pathCache);
|
||||
|
||||
// Get paths available in the provided dirName, allowing proper prioritization of earlier datasources
|
||||
foreach ($pathsCache as $datasourceKey => $sourcePaths) {
|
||||
// Only look at the active datasource if singleDatasourceMode is enabled
|
||||
if ($this->singleDatasourceMode && $datasourceKey !== $this->activeDatasourceKey) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$paths = array_merge($paths, array_filter($sourcePaths, function ($path) use ($dirName, $options) {
|
||||
$basePath = $dirName . '/';
|
||||
|
||||
$inPath = starts_with($path, $basePath);
|
||||
|
||||
// Check the fileMatch if provided as an option
|
||||
$fnMatch = !empty($options['fileMatch']) ? fnmatch($options['fileMatch'], str_after($path, $basePath)) : true;
|
||||
|
||||
// Check the extension if provided as an option
|
||||
$validExt = !empty($options['extensions']) && is_array($options['extensions']) ? in_array(pathinfo($path, PATHINFO_EXTENSION), $options['extensions']) : true;
|
||||
|
||||
return $inPath && $fnMatch && $validExt;
|
||||
}, ARRAY_FILTER_USE_KEY));
|
||||
}
|
||||
|
||||
// Filter out 'deleted' paths:
|
||||
$paths = array_filter($paths, function ($value) {
|
||||
return (bool) $value;
|
||||
});
|
||||
|
||||
// Return just an array of paths
|
||||
return array_keys($paths);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to make file path.
|
||||
*/
|
||||
protected function makeFilePath(string $dirName, string $fileName, string $extension): string
|
||||
{
|
||||
return ltrim($dirName . '/' . $fileName . '.' . $extension, '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the datasource for use with CRUD operations
|
||||
*/
|
||||
protected function getActiveDatasource(): DatasourceInterface
|
||||
{
|
||||
return $this->datasources[$this->activeDatasourceKey];
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function selectOne(string $dirName, string $fileName, string $extension): ?array
|
||||
{
|
||||
try {
|
||||
$path = $this->makeFilePath($dirName, $fileName, $extension);
|
||||
$result = $this->getDatasourceForPath($path)->selectOne($dirName, $fileName, $extension);
|
||||
|
||||
// if result = null, this means that
|
||||
// - a: The requested record doesn't exist
|
||||
// - b: The requested record exists, but is marked deleted
|
||||
// - c: The requested record is reported to exist in a datasource that it doesn't actually exist in
|
||||
if (is_null($result)) {
|
||||
foreach ($this->pathCache as $paths) {
|
||||
// If the path is reported to exist here (and isn't marked deleted) even though the previous attempt
|
||||
// returned nothing, then the paths cache needs to be rebuilt and we should try again
|
||||
if (@$paths[$path]) {
|
||||
$this->populateCache(true);
|
||||
$result = $this->getDatasourceForPath($path)->selectOne($dirName, $fileName, $extension);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception $ex) {
|
||||
$result = null;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function select(string $dirName, array $options = []): array
|
||||
{
|
||||
// Handle fileName listings through just the cache
|
||||
if (@$options['columns'] === ['fileName']) {
|
||||
// Return just filenames of the valid paths for this directory
|
||||
$results = array_values(array_map(function ($path) use ($dirName) {
|
||||
return ['fileName' => str_after($path, $dirName . '/')];
|
||||
}, $this->getValidPaths($dirName, $options)));
|
||||
|
||||
// Retrieve full listings from datasources directly
|
||||
} else {
|
||||
// Initialize result set
|
||||
$sourceResults = [];
|
||||
|
||||
// Reverse the order of the sources so that earlier
|
||||
// sources are prioritized over later sources
|
||||
$datasources = array_reverse($this->datasources);
|
||||
|
||||
foreach ($datasources as $datasource) {
|
||||
$sourceResults = array_merge($sourceResults, $datasource->select($dirName, $options));
|
||||
}
|
||||
|
||||
// Remove duplicate results prioritizing results from earlier datasources
|
||||
$sourceResults = collect($sourceResults)->keyBy('fileName');
|
||||
|
||||
// Get a list of valid filenames from the list of valid paths for this directory
|
||||
$validFiles = array_map(function ($path) use ($dirName) {
|
||||
return str_after($path, $dirName . '/');
|
||||
}, $this->getValidPaths($dirName, $options));
|
||||
|
||||
// Filter out deleted paths
|
||||
$results = array_values($sourceResults->filter(function ($value, $key) use ($validFiles) {
|
||||
return in_array($key, $validFiles);
|
||||
})->all());
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function insert(string $dirName, string $fileName, string $extension, string $content): int
|
||||
{
|
||||
// Insert only on the active datasource
|
||||
$result = $this->getActiveDatasource()->insert($dirName, $fileName, $extension, $content);
|
||||
|
||||
// Refresh the cache
|
||||
$this->populateCache(true);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function update(string $dirName, string $fileName, string $extension, string $content, $oldFileName = null, $oldExtension = null): int
|
||||
{
|
||||
$searchFileName = $oldFileName ?: $fileName;
|
||||
$searchExt = $oldExtension ?: $extension;
|
||||
|
||||
// Ensure that files that are being renamed have their old names marked as deleted prior to inserting the renamed file
|
||||
// Also ensure that the cache only gets updated at the end of this operation instead of twice, once here and again at the end
|
||||
if ($searchFileName !== $fileName || $searchExt !== $extension) {
|
||||
$this->allowCacheRefreshes = false;
|
||||
$this->delete($dirName, $searchFileName, $searchExt);
|
||||
$this->allowCacheRefreshes = true;
|
||||
}
|
||||
|
||||
$datasource = $this->getActiveDatasource();
|
||||
|
||||
if (!empty($datasource->selectOne($dirName, $searchFileName, $searchExt))) {
|
||||
$result = $datasource->update($dirName, $fileName, $extension, $content, $oldFileName, $oldExtension);
|
||||
} else {
|
||||
$result = $datasource->insert($dirName, $fileName, $extension, $content);
|
||||
}
|
||||
|
||||
// Refresh the cache
|
||||
$this->populateCache(true);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function delete(string $dirName, string $fileName, string $extension): bool
|
||||
{
|
||||
try {
|
||||
// Delete from only the active datasource
|
||||
if ($this->forceDeleting) {
|
||||
$success = $this->getActiveDatasource()->forceDelete($dirName, $fileName, $extension);
|
||||
} else {
|
||||
$success = $this->getActiveDatasource()->delete($dirName, $fileName, $extension);
|
||||
}
|
||||
}
|
||||
catch (Exception $ex) {
|
||||
// Only attempt to do an insert-delete when not force deleting the record
|
||||
if (!$this->forceDeleting) {
|
||||
// Check to see if this is a valid path to delete
|
||||
$path = $this->makeFilePath($dirName, $fileName, $extension);
|
||||
|
||||
if (in_array($path, $this->getValidPaths($dirName))) {
|
||||
// Retrieve the current record
|
||||
$record = $this->selectOne($dirName, $fileName, $extension);
|
||||
|
||||
// Insert the current record into the active datasource so we can mark it as deleted
|
||||
$this->insert($dirName, $fileName, $extension, $record['content']);
|
||||
|
||||
// Perform the deletion on the newly inserted record
|
||||
$success = $this->delete($dirName, $fileName, $extension);
|
||||
} else {
|
||||
throw (new DeleteFileException)->setInvalidPath($path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh the cache
|
||||
$this->populateCache(true);
|
||||
|
||||
return $success;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function lastModified(string $dirName, string $fileName, string $extension): ?int
|
||||
{
|
||||
$path = $this->makeFilePath($dirName, $fileName, $extension);
|
||||
|
||||
// Database datasources record modification times in the path cache, which lets the
|
||||
// Halcyon cache validate itself without querying the database on every request.
|
||||
// Anything else (filesystem sources report `true`, deleted paths report `false`)
|
||||
// falls through to the datasource so its modification time stays live.
|
||||
if (!$this->singleDatasourceMode && is_int($mtime = $this->getPathCacheEntry($path))) {
|
||||
return $mtime;
|
||||
}
|
||||
|
||||
return $this->getDatasourceForPath($path)->lastModified($dirName, $fileName, $extension);
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function makeCacheKey($name = ''): string
|
||||
{
|
||||
$key = '';
|
||||
|
||||
foreach ($this->datasources as $datasource) {
|
||||
$key .= $datasource->makeCacheKey($name) . '-';
|
||||
}
|
||||
$key .= $name;
|
||||
|
||||
return hash('crc32b', $key);
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function getPathsCacheKey(): string
|
||||
{
|
||||
return $this->cacheKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function getAvailablePaths(): array
|
||||
{
|
||||
$paths = [];
|
||||
$datasources = array_reverse($this->datasources);
|
||||
foreach ($datasources as $datasource) {
|
||||
$paths = array_merge($paths, $datasource->getAvailablePaths());
|
||||
}
|
||||
return $paths;
|
||||
}
|
||||
}
|
||||
493
modules/cms/classes/CmsCompoundObject.php
Normal file
493
modules/cms/classes/CmsCompoundObject.php
Normal file
@@ -0,0 +1,493 @@
|
||||
<?php namespace Cms\Classes;
|
||||
|
||||
use App;
|
||||
use Ini;
|
||||
use Lang;
|
||||
use Cache;
|
||||
use Config;
|
||||
use Cms\Components\ViewBag;
|
||||
use Cms\Helpers\Cms as CmsHelpers;
|
||||
use Winter\Storm\Halcyon\Processors\SectionParser;
|
||||
use Twig\Source as TwigSource;
|
||||
use ApplicationException;
|
||||
|
||||
/**
|
||||
* This is a base class for CMS objects that have multiple sections - pages, partials and layouts.
|
||||
* The class implements functionality for the compound object file parsing. It also provides a way
|
||||
* to access parameters defined in the INI settings section as the object properties.
|
||||
*
|
||||
* @package winter\wn-cms-module
|
||||
* @author Alexey Bobkov, Samuel Georges
|
||||
*/
|
||||
class CmsCompoundObject extends CmsObject
|
||||
{
|
||||
/**
|
||||
* @var array Initialized components defined in the template file.
|
||||
*/
|
||||
public $components = [];
|
||||
|
||||
/**
|
||||
* @var array INI settings defined in the template file. Not to be confused
|
||||
* with the attribute called settings. In this array, components are bumped
|
||||
* to their own array inside the 'components' key.
|
||||
*/
|
||||
public $settings = [
|
||||
'components' => []
|
||||
];
|
||||
|
||||
/**
|
||||
* @var array Contains the view bag properties.
|
||||
* This property is used by the page editor internally.
|
||||
*/
|
||||
public $viewBag = [];
|
||||
|
||||
/**
|
||||
* @var array The attributes that are mass assignable.
|
||||
*/
|
||||
protected $fillable = [
|
||||
'markup',
|
||||
'settings',
|
||||
'code'
|
||||
];
|
||||
|
||||
/**
|
||||
* The methods that should be returned from the collection of all objects.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $passthru = [
|
||||
'lists',
|
||||
'where',
|
||||
'sortBy',
|
||||
'whereComponent',
|
||||
'withComponent'
|
||||
];
|
||||
|
||||
/**
|
||||
* @var bool Model supports code and settings sections.
|
||||
*/
|
||||
protected $isCompoundObject = true;
|
||||
|
||||
/**
|
||||
* @var array|null Cache for component properties.
|
||||
*/
|
||||
protected static $objectComponentPropertyMap;
|
||||
|
||||
/**
|
||||
* @var mixed Cache store for the getViewBag method.
|
||||
*/
|
||||
protected $viewBagCache = false;
|
||||
|
||||
/**
|
||||
* Triggered after the object is loaded.
|
||||
* @return void
|
||||
*/
|
||||
public function afterFetch()
|
||||
{
|
||||
$this->parseComponentSettings();
|
||||
$this->validateSettings();
|
||||
$this->parseSettings();
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggered when the model is saved.
|
||||
* @return void
|
||||
*/
|
||||
public function beforeSave()
|
||||
{
|
||||
// Ignore line-ending only changes to the code property to avoid triggering safe mode
|
||||
// when no changes actually occurred, it was just the browser reformatting line endings
|
||||
if ($this->isDirty('code')) {
|
||||
$oldCode = str_replace("\n", "\r\n", str_replace("\r", '', $this->getOriginal('code')));
|
||||
$newCode = str_replace("\n", "\r\n", str_replace("\r", '', $this->code));
|
||||
if ($oldCode === $newCode) {
|
||||
$this->code = $this->getOriginal('code');
|
||||
}
|
||||
}
|
||||
|
||||
$this->checkSafeMode();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new Collection instance.
|
||||
*
|
||||
* @param array $models
|
||||
* @return \Winter\Storm\Halcyon\Collection
|
||||
*/
|
||||
public function newCollection(array $models = [])
|
||||
{
|
||||
return new CmsObjectCollection($models);
|
||||
}
|
||||
|
||||
/**
|
||||
* If the model is loaded with an invalid INI section, the invalid content will be
|
||||
* passed as a special attribute. Look for it, then locate the failure reason.
|
||||
* @return void
|
||||
*/
|
||||
protected function validateSettings()
|
||||
{
|
||||
if (isset($this->attributes[SectionParser::ERROR_INI])) {
|
||||
CmsException::mask($this, 200);
|
||||
Ini::parse($this->attributes[SectionParser::ERROR_INI]);
|
||||
CmsException::unmask();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the settings array.
|
||||
* Child classes can override this method in order to update the content
|
||||
* of the $settings property after the object is loaded from a file.
|
||||
* @return void
|
||||
*/
|
||||
protected function parseSettings()
|
||||
{
|
||||
$this->fillViewBagArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* This method checks if safe mode is enabled by config, and the code
|
||||
* attribute is modified and populated. If so an exception is thrown.
|
||||
* @return void
|
||||
*/
|
||||
protected function checkSafeMode()
|
||||
{
|
||||
if (CmsHelpers::safeModeEnabled() && $this->isDirty('code') && strlen(trim($this->code))) {
|
||||
throw new ApplicationException(Lang::get('cms::lang.cms_object.safe_mode_enabled'));
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Components
|
||||
//
|
||||
|
||||
/**
|
||||
* Runs components defined in the settings
|
||||
* Process halts if a component returns a value
|
||||
* @return void
|
||||
*/
|
||||
public function runComponents()
|
||||
{
|
||||
foreach ($this->components as $component) {
|
||||
if ($event = $component->fireEvent('component.beforeRun', [], true)) {
|
||||
return $event;
|
||||
}
|
||||
|
||||
if ($result = $component->onRun()) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
if ($event = $component->fireEvent('component.run', [], true)) {
|
||||
return $event;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse component sections.
|
||||
* Replace the multiple component sections with a single "components"
|
||||
* element in the $settings property.
|
||||
* @return void
|
||||
*/
|
||||
protected function parseComponentSettings()
|
||||
{
|
||||
$this->settings = $this->getSettingsAttribute();
|
||||
|
||||
$manager = ComponentManager::instance();
|
||||
$components = [];
|
||||
foreach ($this->settings as $setting => $value) {
|
||||
if (!is_array($value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$settingParts = explode(' ', $setting);
|
||||
$settingName = $settingParts[0];
|
||||
|
||||
$components[$setting] = $value;
|
||||
unset($this->settings[$setting]);
|
||||
}
|
||||
|
||||
$this->settings['components'] = $components;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a component by its name.
|
||||
* This method is used only in the back-end and for internal system needs when
|
||||
* the standard way to access components is not an option.
|
||||
* @param string $componentName Specifies the component name.
|
||||
* @return \Cms\Classes\ComponentBase Returns the component instance or null.
|
||||
*/
|
||||
public function getComponent($componentName)
|
||||
{
|
||||
if (!($componentSection = $this->hasComponent($componentName))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ComponentManager::instance()->makeComponent(
|
||||
$componentName,
|
||||
null,
|
||||
$this->settings['components'][$componentSection]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the object has a component with the specified name.
|
||||
* @param string $componentName Specifies the component name.
|
||||
* @return mixed Return false or the full component name used on the page (it could include the alias).
|
||||
*/
|
||||
public function hasComponent($componentName)
|
||||
{
|
||||
$componentManager = ComponentManager::instance();
|
||||
$componentName = $componentManager->resolve($componentName);
|
||||
|
||||
foreach ($this->settings['components'] as $sectionName => $values) {
|
||||
$result = $sectionName;
|
||||
|
||||
if ($sectionName == $componentName) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
$parts = explode(' ', $sectionName);
|
||||
if (count($parts) > 1) {
|
||||
$sectionName = trim($parts[0]);
|
||||
|
||||
if ($sectionName == $componentName) {
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
|
||||
$sectionName = $componentManager->resolve($sectionName);
|
||||
if ($sectionName == $componentName) {
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns component property names and values.
|
||||
* This method implements caching and can be used in the run-time on the front-end.
|
||||
* @param string $componentName Specifies the component name.
|
||||
* @return array Returns an associative array with property names in the keys and property values in the values.
|
||||
*/
|
||||
public function getComponentProperties($componentName)
|
||||
{
|
||||
$key = md5($this->theme->getPath()).'component-properties';
|
||||
|
||||
if (self::$objectComponentPropertyMap !== null) {
|
||||
$objectComponentMap = self::$objectComponentPropertyMap;
|
||||
}
|
||||
else {
|
||||
$cached = Cache::get($key, false);
|
||||
$unserialized = $cached ? @unserialize(@base64_decode($cached)) : false;
|
||||
$objectComponentMap = $unserialized ?: [];
|
||||
if ($objectComponentMap) {
|
||||
self::$objectComponentPropertyMap = $objectComponentMap;
|
||||
}
|
||||
}
|
||||
|
||||
$objectCode = $this->getBaseFileName();
|
||||
|
||||
if (array_key_exists($objectCode, $objectComponentMap)) {
|
||||
if (array_key_exists($componentName, $objectComponentMap[$objectCode])) {
|
||||
return $objectComponentMap[$objectCode][$componentName];
|
||||
}
|
||||
|
||||
return [];
|
||||
} else {
|
||||
$objectComponentMap[$objectCode] = [];
|
||||
}
|
||||
|
||||
if (!isset($this->settings['components'])) {
|
||||
$objectComponentMap[$objectCode] = [];
|
||||
}
|
||||
else {
|
||||
foreach ($this->settings['components'] as $name => $settings) {
|
||||
$nameParts = explode(' ', $name);
|
||||
if (count($nameParts) > 1) {
|
||||
$name = trim($nameParts[0]);
|
||||
}
|
||||
|
||||
$component = $this->getComponent($name);
|
||||
if (!$component) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$componentProperties = [];
|
||||
$propertyDefinitions = $component->defineProperties();
|
||||
foreach ($propertyDefinitions as $propertyName => $propertyInfo) {
|
||||
$componentProperties[$propertyName] = $component->property($propertyName);
|
||||
}
|
||||
|
||||
$objectComponentMap[$objectCode][$name] = $componentProperties;
|
||||
}
|
||||
}
|
||||
|
||||
self::$objectComponentPropertyMap = $objectComponentMap;
|
||||
|
||||
$expiresAt = now()->addMinutes(Config::get('cms.parsedPageCacheTTL', 10));
|
||||
Cache::put($key, base64_encode(serialize($objectComponentMap)), $expiresAt);
|
||||
|
||||
if (array_key_exists($componentName, $objectComponentMap[$objectCode])) {
|
||||
return $objectComponentMap[$objectCode][$componentName];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the object cache.
|
||||
* @param \Cms\Classes\Theme $theme Specifies a parent theme.
|
||||
* @return void
|
||||
*/
|
||||
public static function clearCache($theme)
|
||||
{
|
||||
$key = md5($theme->getPath()).'component-properties';
|
||||
Cache::forget($key);
|
||||
}
|
||||
|
||||
//
|
||||
// View Bag
|
||||
//
|
||||
|
||||
/**
|
||||
* Returns the configured view bag component.
|
||||
* This method is used only in the back-end and for internal system needs when
|
||||
* the standard way to access components is not an option.
|
||||
* @return \Cms\Components\ViewBag Returns the view bag component instance.
|
||||
*/
|
||||
public function getViewBag()
|
||||
{
|
||||
if ($this->viewBagCache !== false) {
|
||||
return $this->viewBagCache;
|
||||
}
|
||||
|
||||
$componentName = 'viewBag';
|
||||
|
||||
if (!isset($this->settings['components'][$componentName])) {
|
||||
$viewBag = new ViewBag(null, []);
|
||||
$viewBag->name = $componentName;
|
||||
|
||||
return $this->viewBagCache = $viewBag;
|
||||
}
|
||||
|
||||
return $this->viewBagCache = $this->getComponent($componentName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies view bag properties to the view bag array.
|
||||
* This is required for the back-end editors.
|
||||
* @return void
|
||||
*/
|
||||
protected function fillViewBagArray()
|
||||
{
|
||||
$viewBag = $this->getViewBag();
|
||||
foreach ($viewBag->getProperties() as $name => $value) {
|
||||
$this->viewBag[$name] = $value;
|
||||
}
|
||||
|
||||
$this->fireEvent('cmsObject.fillViewBagArray');
|
||||
}
|
||||
|
||||
//
|
||||
// Twig
|
||||
//
|
||||
|
||||
/**
|
||||
* Returns the Twig content string
|
||||
* @return string
|
||||
*/
|
||||
public function getTwigContent()
|
||||
{
|
||||
return $this->markup;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns Twig node tree generated from the object's markup.
|
||||
* This method is used by the system internally and shouldn't
|
||||
* participate in the front-end request processing.
|
||||
* @link http://twig.sensiolabs.org/doc/internals.html Twig internals
|
||||
* @param mixed $markup Specifies the markup content.
|
||||
* Use FALSE to load the content from the markup section.
|
||||
* @return Twig\Node\ModuleNode A node tree
|
||||
*/
|
||||
public function getTwigNodeTree($markup = false)
|
||||
{
|
||||
$twig = App::make('twig.environment.cms');
|
||||
$stream = $twig->tokenize(new TwigSource($markup === false ? $this->markup : $markup, 'getTwigNodeTree'));
|
||||
return $twig->parse($stream);
|
||||
}
|
||||
|
||||
//
|
||||
// Magic
|
||||
//
|
||||
|
||||
/**
|
||||
* Implements getter functionality for visible properties defined in
|
||||
* the settings section or view bag array.
|
||||
*/
|
||||
public function __get($name)
|
||||
{
|
||||
if (is_array($this->settings) && array_key_exists($name, $this->settings)) {
|
||||
return $this->settings[$name];
|
||||
}
|
||||
|
||||
if (is_array($this->viewBag) && array_key_exists($name, $this->viewBag)) {
|
||||
return $this->viewBag[$name];
|
||||
}
|
||||
|
||||
return parent::__get($name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamically set attributes on the model.
|
||||
*
|
||||
* @param string $key
|
||||
* @param mixed $value
|
||||
* @return void
|
||||
*/
|
||||
public function __set($key, $value)
|
||||
{
|
||||
parent::__set($key, $value);
|
||||
|
||||
if (array_key_exists($key, $this->settings)) {
|
||||
$this->settings[$key] = $this->attributes[$key];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if an attribute exists on the object.
|
||||
*
|
||||
* @param string $key
|
||||
* @return bool
|
||||
*/
|
||||
public function __isset($key)
|
||||
{
|
||||
if (parent::__isset($key) === true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isset($this->viewBag[$key]) === true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return isset($this->settings[$key]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamically handle calls into the query instance.
|
||||
*
|
||||
* @param string $method
|
||||
* @param array $parameters
|
||||
* @return mixed
|
||||
*/
|
||||
public function __call($method, $parameters)
|
||||
{
|
||||
if (in_array($method, $this->passthru)) {
|
||||
$collection = $this->get();
|
||||
return call_user_func_array([$collection, $method], $parameters);
|
||||
}
|
||||
|
||||
return parent::__call($method, $parameters);
|
||||
}
|
||||
}
|
||||
71
modules/cms/classes/CmsController.php
Normal file
71
modules/cms/classes/CmsController.php
Normal file
@@ -0,0 +1,71 @@
|
||||
<?php namespace Cms\Classes;
|
||||
|
||||
use App;
|
||||
use Closure;
|
||||
use Illuminate\Routing\Controller as ControllerBase;
|
||||
|
||||
/**
|
||||
* This is the master controller for all front-end pages.
|
||||
* All requests that have not been picked up already by the router will end up here,
|
||||
* then the URL is passed to the front-end controller for processing.
|
||||
*
|
||||
* @see Cms\Classes\Controller Front-end controller class
|
||||
* @package winter\wn-cms-module
|
||||
* @author Alexey Bobkov, Samuel Georges
|
||||
*/
|
||||
class CmsController extends ControllerBase
|
||||
{
|
||||
use \Winter\Storm\Extension\ExtendableTrait;
|
||||
|
||||
/**
|
||||
* @var array Behaviors implemented by this controller.
|
||||
*/
|
||||
public $implement;
|
||||
|
||||
/**
|
||||
* Instantiate a new CmsController instance.
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
$this->extendableConstruct();
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds and serves the request using the primary controller.
|
||||
* @param string $url Specifies the requested page URL.
|
||||
* If the parameter is omitted, the current URL used.
|
||||
* @return BaseResponse Returns the response to the provided URL
|
||||
*/
|
||||
public function run($url = '/')
|
||||
{
|
||||
return App::make(Controller::class)->run($url);
|
||||
}
|
||||
|
||||
public function __call($name, $params)
|
||||
{
|
||||
if ($name === 'extend') {
|
||||
if (empty($params[0]) || !is_callable($params[0])) {
|
||||
throw new \InvalidArgumentException('The extend() method requires a callback parameter or closure.');
|
||||
}
|
||||
if ($params[0] instanceof \Closure) {
|
||||
return $params[0]->call($this, $params[1] ?? $this);
|
||||
}
|
||||
return \Closure::fromCallable($params[0])->call($this, $params[1] ?? $this);
|
||||
}
|
||||
|
||||
return $this->extendableCall($name, $params);
|
||||
}
|
||||
|
||||
public static function __callStatic($name, $params)
|
||||
{
|
||||
if ($name === 'extend') {
|
||||
if (empty($params[0])) {
|
||||
throw new \InvalidArgumentException('The extend() method requires a callback parameter or closure.');
|
||||
}
|
||||
self::extendableExtendCallback($params[0], $params[1] ?? false, $params[2] ?? null);
|
||||
return;
|
||||
}
|
||||
|
||||
return self::extendableCallStatic($name, $params);
|
||||
}
|
||||
}
|
||||
236
modules/cms/classes/CmsException.php
Normal file
236
modules/cms/classes/CmsException.php
Normal file
@@ -0,0 +1,236 @@
|
||||
<?php namespace Cms\Classes;
|
||||
|
||||
use File;
|
||||
use Twig\Error\Error as TwigError;
|
||||
use Winter\Storm\Exception\ApplicationException;
|
||||
use Winter\Storm\Halcyon\Processors\SectionParser;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* The CMS exception class.
|
||||
* The exception class handles CMS related errors. Allows the masking of other exception types which
|
||||
* uses actual source CMS files -- instead of cached files -- for their error content.
|
||||
*
|
||||
* @package winter\wn-cms-module
|
||||
* @author Alexey Bobkov, Samuel Georges
|
||||
*/
|
||||
class CmsException extends ApplicationException
|
||||
{
|
||||
/**
|
||||
* @var \Cms\Classes\CmsCompoundObject A reference to a CMS object used for masking errors.
|
||||
*/
|
||||
protected $compoundObject;
|
||||
|
||||
/**
|
||||
* @var array Collection of error codes for each error distinction.
|
||||
*/
|
||||
protected static $errorCodes = [
|
||||
100 => 'General',
|
||||
200 => 'INI Settings',
|
||||
300 => 'PHP Content',
|
||||
400 => 'Twig Template'
|
||||
];
|
||||
|
||||
/**
|
||||
* Creates the CMS exception object.
|
||||
* @param mixed $message The message to display as a string, or a CmsCompoundObject that is used
|
||||
* for using this exception as a mask for another exception type.
|
||||
* @param int $code Error code to specify the exception type:
|
||||
* Error 100: A general exception.
|
||||
* Error 200: Mask the exception as INI content.
|
||||
* Error 300: Mask the exception as PHP content.
|
||||
* Error 400: Mask the exception as Twig content.
|
||||
* @param Throwable $previous Previous exception.
|
||||
*/
|
||||
public function __construct($message = null, $code = 100, ?Throwable $previous = null)
|
||||
{
|
||||
if ($message instanceof CmsCompoundObject || $message instanceof ComponentPartial) {
|
||||
$this->compoundObject = $message;
|
||||
$message = '';
|
||||
}
|
||||
|
||||
if (isset(static::$errorCodes[$code])) {
|
||||
$this->errorType = static::$errorCodes[$code];
|
||||
}
|
||||
|
||||
parent::__construct($message, $code, $previous);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks some conditions to confirm error has actually occurred
|
||||
* due to the CMS template code, not some external code. If the error
|
||||
* has occurred in external code, the function will return false. Otherwise return
|
||||
* true and modify the exception by overriding it's content, line and message values
|
||||
* to be accurate against a CMS object properties.
|
||||
* @param Throwable $exception The exception to modify.
|
||||
* @return bool
|
||||
*/
|
||||
public function processCompoundObject(Throwable $exception)
|
||||
{
|
||||
switch ($this->code) {
|
||||
case 200:
|
||||
$result = $this->processIni($exception);
|
||||
break;
|
||||
|
||||
case 300:
|
||||
$result = $this->processPhp($exception);
|
||||
break;
|
||||
|
||||
case 400:
|
||||
$result = $this->processTwig($exception);
|
||||
break;
|
||||
}
|
||||
if ($result !== false) {
|
||||
$this->file = $this->compoundObject->getFilePath();
|
||||
|
||||
if (File::isFile($this->file) && is_readable($this->file)) {
|
||||
$this->fileContent = @file($this->file);
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Override properties of an exception specific to the INI section
|
||||
* of a CMS object.
|
||||
* @param Throwable $exception The exception to modify.
|
||||
* @return bool
|
||||
*/
|
||||
protected function processIni(Throwable $exception)
|
||||
{
|
||||
$message = $exception->getMessage();
|
||||
|
||||
/*
|
||||
* Expecting: syntax error, unexpected '!' in Unknown on line 4
|
||||
*/
|
||||
if (!starts_with($message, 'syntax error')) {
|
||||
return false;
|
||||
}
|
||||
if (strpos($message, 'Unknown') === false) {
|
||||
return false;
|
||||
}
|
||||
if (strpos($exception->getFile(), 'Ini.php') === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/*
|
||||
* Line number from parse_ini_string() error.
|
||||
* The last word should contain the line number.
|
||||
*/
|
||||
$parts = explode(' ', $message);
|
||||
$line = array_pop($parts);
|
||||
$this->line = (int)$line;
|
||||
|
||||
// Find where the ini settings section begins
|
||||
$offsetArray = SectionParser::parseOffset($this->compoundObject->getContent());
|
||||
$this->line += $offsetArray['settings'];
|
||||
|
||||
$this->message = $message;
|
||||
|
||||
// Account for line 0
|
||||
$this->line--;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Override properties of an exception specific to the PHP section
|
||||
* of a CMS object.
|
||||
* @param Throwable $exception The exception to modify.
|
||||
* @return bool
|
||||
*/
|
||||
protected function processPhp(Throwable $exception)
|
||||
{
|
||||
/*
|
||||
* Fatal Error
|
||||
*/
|
||||
if ($exception instanceof \Symfony\Component\ErrorHandler\Error\FatalError) {
|
||||
$check = false;
|
||||
|
||||
// Expected: */modules/cms/classes/CodeParser.php(165) : eval()'d code line 7
|
||||
if (strpos($exception->getFile(), 'CodeParser.php')) {
|
||||
$check = true;
|
||||
}
|
||||
|
||||
// Expected: */storage/cms/cache/39/05/home.htm.php
|
||||
if (strpos($exception->getFile(), $this->compoundObject->getFileName() . '.php')) {
|
||||
$check = true;
|
||||
}
|
||||
|
||||
if (!$check) {
|
||||
return false;
|
||||
}
|
||||
/*
|
||||
* Errors occurring the PHP code base class (Cms\Classes\CodeBase)
|
||||
*/
|
||||
}
|
||||
else {
|
||||
$trace = $exception->getTrace();
|
||||
if (isset($trace[1]['class'])) {
|
||||
$class = $trace[1]['class'];
|
||||
if (!is_subclass_of($class, CodeBase::class)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->message = $exception->getMessage();
|
||||
|
||||
// Offset the php, namespace and bracket tags from the generated class.
|
||||
$this->line = $exception->getLine() - 3;
|
||||
|
||||
// Find where the php code section begins
|
||||
$offsetArray = SectionParser::parseOffset($this->compoundObject->getContent());
|
||||
$this->line += $offsetArray['code'];
|
||||
|
||||
// Account for line 0
|
||||
$this->line--;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Override properties of an exception specific to the Twig section
|
||||
* of a CMS object.
|
||||
* @param Throwable $exception The exception to modify.
|
||||
* @return bool
|
||||
*/
|
||||
protected function processTwig(Throwable $exception)
|
||||
{
|
||||
// Must be a Twig related exception
|
||||
if (!$exception instanceof TwigError) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->message = $exception->getRawMessage();
|
||||
$this->line = $exception->getTemplateLine();
|
||||
|
||||
// Find where the twig markup section begins
|
||||
$offsetArray = SectionParser::parseOffset($this->compoundObject->getContent());
|
||||
$this->line += $offsetArray['markup'];
|
||||
|
||||
// Account for line 0
|
||||
$this->line--;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Masks this exception with the details of the supplied. The error code for
|
||||
* this exception object will determine how the supplied exception is used.
|
||||
* Error 100: A general exception. Inherits \Winter\Storm\Exception\ExceptionBase::applyMask()
|
||||
* Error 200: Mask the exception as INI content.
|
||||
* Error 300: Mask the exception as PHP content.
|
||||
* Error 400: Mask the exception as Twig content.
|
||||
* @param Throwable $exception The exception to modify.
|
||||
* @return void
|
||||
*/
|
||||
public function applyMask(Throwable $exception)
|
||||
{
|
||||
if ($this->code == 100 || $this->processCompoundObject($exception) === false) {
|
||||
parent::applyMask($exception);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
365
modules/cms/classes/CmsObject.php
Normal file
365
modules/cms/classes/CmsObject.php
Normal file
@@ -0,0 +1,365 @@
|
||||
<?php namespace Cms\Classes;
|
||||
|
||||
use App;
|
||||
use Lang;
|
||||
use Event;
|
||||
use Config;
|
||||
use Exception;
|
||||
use ValidationException;
|
||||
use ApplicationException;
|
||||
use Cms\Contracts\CmsObject as CmsObjectContract;
|
||||
use Winter\Storm\Filesystem\PathResolver;
|
||||
use Winter\Storm\Halcyon\Model as HalcyonModel;
|
||||
|
||||
/**
|
||||
* This is a base class for all CMS objects - content files, pages, partials and layouts.
|
||||
* The class implements basic operations with file-based templates.
|
||||
*
|
||||
* @package winter\wn-cms-module
|
||||
* @author Alexey Bobkov, Samuel Georges
|
||||
*/
|
||||
class CmsObject extends HalcyonModel implements CmsObjectContract
|
||||
{
|
||||
use \Winter\Storm\Halcyon\Traits\Validation;
|
||||
|
||||
/**
|
||||
* @var array The rules to be applied to the data.
|
||||
*/
|
||||
public $rules = [];
|
||||
|
||||
/**
|
||||
* @var array The array of custom attribute names.
|
||||
*/
|
||||
public $attributeNames = [];
|
||||
|
||||
/**
|
||||
* @var array The array of custom error messages.
|
||||
*/
|
||||
public $customMessages = [];
|
||||
|
||||
/**
|
||||
* @var int The maximum allowed path nesting level. The default value is 2,
|
||||
* meaning that files can only exist in the root directory, or in a
|
||||
* subdirectory. Set to null if any level is allowed.
|
||||
*/
|
||||
protected $maxNesting = null;
|
||||
|
||||
/**
|
||||
* @var array The attributes that are mass assignable.
|
||||
*/
|
||||
protected $fillable = [
|
||||
'content'
|
||||
];
|
||||
|
||||
/**
|
||||
* @var bool Model supports code and settings sections.
|
||||
*/
|
||||
protected $isCompoundObject = false;
|
||||
|
||||
/**
|
||||
* @var \Cms\Classes\Theme A reference to the CMS theme containing the object.
|
||||
*/
|
||||
protected $themeCache;
|
||||
|
||||
/**
|
||||
* The "booting" method of the model.
|
||||
* @return void
|
||||
*/
|
||||
protected static function boot()
|
||||
{
|
||||
parent::boot();
|
||||
static::bootDefaultTheme();
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot all of the bootable traits on the model.
|
||||
* @return void
|
||||
*/
|
||||
protected static function bootDefaultTheme()
|
||||
{
|
||||
$resolver = static::getDatasourceResolver();
|
||||
if ($resolver->getDefaultDatasource()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$defaultTheme = App::runningInBackend()
|
||||
? Theme::getEditThemeCode()
|
||||
: Theme::getActiveThemeCode();
|
||||
|
||||
Theme::load($defaultTheme);
|
||||
|
||||
$resolver->setDefaultDatasource($defaultTheme);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the object from a file.
|
||||
* This method is used in the CMS back-end. It doesn't use any caching.
|
||||
* @param mixed $theme Specifies the theme the object belongs to.
|
||||
* @param string $fileName Specifies the file name, with the extension.
|
||||
* The file name can contain only alphanumeric symbols, dashes and dots.
|
||||
*/
|
||||
public static function load($theme, $fileName): ?static
|
||||
{
|
||||
return static::inTheme($theme)->find($fileName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the object from a cache.
|
||||
* This method is used by the CMS in the runtime. If the cache is not found, it is created.
|
||||
* @param \Cms\Classes\Theme $theme Specifies the theme the object belongs to.
|
||||
* @param string $fileName Specifies the file name, with the extension.
|
||||
* @return static|null Returns a CMS object instance or null if the object wasn't found.
|
||||
*/
|
||||
public static function loadCached($theme, $fileName): ?static
|
||||
{
|
||||
return static::inTheme($theme)
|
||||
->remember(Config::get('cms.parsedPageCacheTTL', 1440))
|
||||
->find($fileName)
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the list of objects in the specified theme.
|
||||
* This method is used internally by the system.
|
||||
* @param \Cms\Classes\Theme $theme Specifies a parent theme.
|
||||
* @param boolean $skipCache Indicates if objects should be reloaded from the disk bypassing the cache.
|
||||
* @return CmsObjectCollection Returns a collection of CMS objects.
|
||||
*/
|
||||
public static function listInTheme($theme, $skipCache = false)
|
||||
{
|
||||
$result = [];
|
||||
$instance = static::inTheme($theme);
|
||||
|
||||
if ($skipCache) {
|
||||
$result = $instance->get();
|
||||
} else {
|
||||
$items = $instance->newQuery()->lists('fileName');
|
||||
|
||||
$loadedItems = [];
|
||||
foreach ($items as $item) {
|
||||
$loaded = static::loadCached($theme, $item);
|
||||
if ($loaded) {
|
||||
$loadedItems[] = $loaded;
|
||||
}
|
||||
unset($loaded);
|
||||
}
|
||||
|
||||
$result = $instance->newCollection($loadedItems);
|
||||
}
|
||||
|
||||
/**
|
||||
* @event cms.object.listInTheme
|
||||
* Provides opportunity to filter the items returned by a call to CmsObject::listInTheme()
|
||||
*
|
||||
* Parameters provided are `$cmsObject` (the object being listed) and `$objectList` (a collection of the CmsObjects being returned).
|
||||
* > Note: The `$objectList` provided is an object reference to a CmsObjectCollection, to make changes you must use object modifying methods.
|
||||
*
|
||||
* Example usage (filters all pages except for the 404 page on the CMS Maintenance mode settings page):
|
||||
*
|
||||
* // Extend only the Settings Controller
|
||||
* \System\Controllers\Settings::extend(function ($controller) {
|
||||
* // Listen for the cms.object.listInTheme event
|
||||
* \Event::listen('cms.object.listInTheme', function ($cmsObject, $objectList) {
|
||||
* // Get the current context of the Settings Manager to ensure we only affect what we need to affect
|
||||
* $context = \System\Classes\SettingsManager::instance()->getContext();
|
||||
* if ($context->owner === 'winter.cms' && $context->itemCode === 'maintenance_settings') {
|
||||
* // Double check that this is a Page List that we're modifying
|
||||
* if ($cmsObject instanceof \Cms\Classes\Page) {
|
||||
* // Perform filtering with an original-object modifying method as $objectList is passed by reference (being that it's an object)
|
||||
* foreach ($objectList as $index => $page) {
|
||||
* if ($page->url !== '/404') {
|
||||
* $objectList->forget($index);
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
* });
|
||||
* });
|
||||
*/
|
||||
Event::fire('cms.object.listInTheme', [$instance, $result]);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares the theme datasource for the model.
|
||||
* @param \Cms\Classes\Theme $theme Specifies a parent theme.
|
||||
* @return static
|
||||
*/
|
||||
public static function inTheme($theme)
|
||||
{
|
||||
if (is_string($theme)) {
|
||||
$theme = Theme::load($theme);
|
||||
}
|
||||
|
||||
return static::on($theme->getDirName());
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the object to the theme.
|
||||
*
|
||||
* @param array $options
|
||||
* @return bool
|
||||
*/
|
||||
public function save(?array $options = null)
|
||||
{
|
||||
try {
|
||||
parent::save($options);
|
||||
}
|
||||
catch (Exception $ex) {
|
||||
$this->throwHalcyonSaveException($ex);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the CMS theme this object belongs to.
|
||||
* @return \Cms\Classes\Theme
|
||||
*/
|
||||
public function getThemeAttribute()
|
||||
{
|
||||
if ($this->themeCache !== null) {
|
||||
return $this->themeCache;
|
||||
}
|
||||
|
||||
$themeName = $this->getDatasourceName()
|
||||
?: static::getDatasourceResolver()->getDefaultDatasource();
|
||||
|
||||
return $this->themeCache = Theme::load($themeName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the full path to the template file corresponding to this object.
|
||||
* @param string $fileName
|
||||
* @return string
|
||||
*/
|
||||
public function getFilePath($fileName = null)
|
||||
{
|
||||
if ($fileName === null) {
|
||||
$fileName = $this->fileName;
|
||||
}
|
||||
|
||||
$directory = $this->theme->getPath() . '/' . $this->getObjectTypeDirName() . '/';
|
||||
$filePath = $directory . $fileName;
|
||||
|
||||
// Limit paths to those under the corresponding theme directory
|
||||
if (!PathResolver::within($filePath, $directory)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return PathResolver::resolve($filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the file name.
|
||||
* @return string
|
||||
*/
|
||||
public function getFileName()
|
||||
{
|
||||
return $this->fileName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the file name without the extension.
|
||||
* @return string
|
||||
*/
|
||||
public function getBaseFileName()
|
||||
{
|
||||
$pos = strrpos($this->fileName, '.');
|
||||
if ($pos === false) {
|
||||
return $this->fileName;
|
||||
}
|
||||
|
||||
return substr($this->fileName, 0, $pos);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper for {{ page.id }} or {{ layout.id }} twig vars
|
||||
* Returns a unique string for this object.
|
||||
* @return string
|
||||
*/
|
||||
public function getId()
|
||||
{
|
||||
return str_replace('/', '-', $this->getBaseFileName());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the file content.
|
||||
* @return string
|
||||
*/
|
||||
public function getContent()
|
||||
{
|
||||
return $this->content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Twig content string.
|
||||
* @return string
|
||||
*/
|
||||
public function getTwigContent()
|
||||
{
|
||||
return $this->content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the key used by the Twig cache.
|
||||
* @return string
|
||||
*/
|
||||
public function getTwigCacheKey()
|
||||
{
|
||||
$key = $this->getFilePath();
|
||||
|
||||
if ($event = $this->fireEvent('cmsObject.getTwigCacheKey', compact('key'), true)) {
|
||||
$key = $event;
|
||||
}
|
||||
|
||||
return $key;
|
||||
}
|
||||
|
||||
//
|
||||
// Internals
|
||||
//
|
||||
|
||||
/**
|
||||
* Converts an exception type thrown by Halcyon to a native CMS exception.
|
||||
* @param Exception $ex
|
||||
*/
|
||||
protected function throwHalcyonSaveException(Exception $ex)
|
||||
{
|
||||
if ($ex instanceof \Winter\Storm\Halcyon\Exception\MissingFileNameException) {
|
||||
throw new ValidationException([
|
||||
'fileName' => Lang::get('cms::lang.cms_object.file_name_required')
|
||||
]);
|
||||
}
|
||||
elseif ($ex instanceof \Winter\Storm\Halcyon\Exception\InvalidExtensionException) {
|
||||
throw new ValidationException(['fileName' =>
|
||||
Lang::get('cms::lang.cms_object.invalid_file_extension', [
|
||||
'allowed' => implode(', ', $ex->getAllowedExtensions()),
|
||||
'invalid' => $ex->getInvalidExtension()
|
||||
])
|
||||
]);
|
||||
}
|
||||
elseif ($ex instanceof \Winter\Storm\Halcyon\Exception\InvalidFileNameException) {
|
||||
throw new ValidationException([
|
||||
'fileName' => Lang::get('cms::lang.cms_object.invalid_file', ['name'=>$ex->getInvalidFileName()])
|
||||
]);
|
||||
}
|
||||
elseif ($ex instanceof \Winter\Storm\Halcyon\Exception\FileExistsException) {
|
||||
throw new ApplicationException(
|
||||
Lang::get('cms::lang.cms_object.file_already_exists', ['name' => $ex->getInvalidPath()])
|
||||
);
|
||||
}
|
||||
elseif ($ex instanceof \Winter\Storm\Halcyon\Exception\CreateDirectoryException) {
|
||||
throw new ApplicationException(
|
||||
Lang::get('cms::lang.cms_object.error_creating_directory', ['name' => $ex->getInvalidPath()])
|
||||
);
|
||||
}
|
||||
elseif ($ex instanceof \Winter\Storm\Halcyon\Exception\CreateFileException) {
|
||||
throw new ApplicationException(
|
||||
Lang::get('cms::lang.cms_object.error_saving', ['name' => $ex->getInvalidPath()])
|
||||
);
|
||||
}
|
||||
else {
|
||||
throw $ex;
|
||||
}
|
||||
}
|
||||
}
|
||||
119
modules/cms/classes/CmsObjectCollection.php
Normal file
119
modules/cms/classes/CmsObjectCollection.php
Normal file
@@ -0,0 +1,119 @@
|
||||
<?php namespace Cms\Classes;
|
||||
|
||||
use ApplicationException;
|
||||
use Winter\Storm\Support\Collection as CollectionBase;
|
||||
|
||||
/**
|
||||
* This class represents a collection of Cms Objects.
|
||||
*
|
||||
* @package winter\wn-cms-module
|
||||
* @author Alexey Bobkov, Samuel Georges
|
||||
*/
|
||||
class CmsObjectCollection extends CollectionBase
|
||||
{
|
||||
/**
|
||||
* Returns objects that use the supplied component.
|
||||
* @param string|array $components
|
||||
* @param null|callback $callback
|
||||
* @return static
|
||||
*/
|
||||
public function withComponent($components, $callback = null)
|
||||
{
|
||||
return $this->filter(function ($object) use ($components, $callback) {
|
||||
$hasComponent = false;
|
||||
|
||||
foreach ((array) $components as $componentName) {
|
||||
if (!$callback && $object->hasComponent($componentName)) {
|
||||
$hasComponent = true;
|
||||
}
|
||||
|
||||
if ($callback && ($component = $object->getComponent($componentName))) {
|
||||
$hasComponent = call_user_func($callback, $component) ?: $hasComponent;
|
||||
}
|
||||
}
|
||||
|
||||
return $hasComponent;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns objects whose properties match the supplied value.
|
||||
*
|
||||
* Note that this deviates from Laravel 6's Illuminate\Support\Traits\EnumeratesValues::where() method signature,
|
||||
* which uses ($key, $operator = null, $value = null) as parameters and that this class extends.
|
||||
*
|
||||
* To ensure backwards compatibility with our current Halcyon functionality, this method retains the original
|
||||
* parameters and functions the same way as before, with handling for the $value and $strict parameters to ensure
|
||||
* they match the previously expected formats. This means that you cannot use operators for "where" queries on
|
||||
* CMS object collections.
|
||||
*
|
||||
* @param string $property
|
||||
* @param string $value
|
||||
* @param bool $strict
|
||||
* @return static
|
||||
*/
|
||||
public function where($property, $value = null, $strict = null)
|
||||
{
|
||||
if (empty($value) || !is_string($value)) {
|
||||
throw new ApplicationException('You must provide a string value to compare with when executing a "where" '
|
||||
. 'query for CMS object collections.');
|
||||
}
|
||||
|
||||
if (!isset($strict) || !is_bool($strict)) {
|
||||
$strict = true;
|
||||
}
|
||||
|
||||
return $this->filter(function ($object) use ($property, $value, $strict) {
|
||||
if (!array_key_exists($property, $object->settings)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $strict
|
||||
? $object->settings[$property] === $value
|
||||
: $object->settings[$property] == $value;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns objects whose component properties match the supplied value.
|
||||
* @param mixed $components
|
||||
* @param string $property
|
||||
* @param string $value
|
||||
* @param bool $strict
|
||||
* @return static
|
||||
*/
|
||||
public function whereComponent($components, $property, $value, $strict = false)
|
||||
{
|
||||
return $this->filter(function ($object) use ($components, $property, $value, $strict) {
|
||||
|
||||
$hasComponent = false;
|
||||
|
||||
foreach ((array) $components as $componentName) {
|
||||
if (!$componentAlias = $object->hasComponent($componentName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$componentSettings = array_get($object->settings, 'components', []);
|
||||
|
||||
if (!array_key_exists($componentAlias, $componentSettings)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$settings = $componentSettings[$componentAlias];
|
||||
|
||||
if (!array_key_exists($property, $settings)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
($strict && $settings[$property] === $value) ||
|
||||
(!$strict && $settings[$property] == $value)
|
||||
) {
|
||||
$hasComponent = true;
|
||||
}
|
||||
}
|
||||
|
||||
return $hasComponent;
|
||||
});
|
||||
}
|
||||
}
|
||||
168
modules/cms/classes/CodeBase.php
Normal file
168
modules/cms/classes/CodeBase.php
Normal file
@@ -0,0 +1,168 @@
|
||||
<?php namespace Cms\Classes;
|
||||
|
||||
use ArrayAccess;
|
||||
use Winter\Storm\Extension\Extendable;
|
||||
|
||||
/**
|
||||
* Parent class for PHP classes created for layout and page code sections.
|
||||
*
|
||||
* @package winter\wn-cms-module
|
||||
* @author Alexey Bobkov, Samuel Georges
|
||||
*/
|
||||
class CodeBase extends Extendable implements ArrayAccess
|
||||
{
|
||||
/**
|
||||
* @var \Cms\Classes\Page Specifies the current page
|
||||
*/
|
||||
public $page;
|
||||
|
||||
/**
|
||||
* @var \Cms\Classes\Layout Specifies the current layout
|
||||
*/
|
||||
public $layout;
|
||||
|
||||
/**
|
||||
* @var \Cms\Classes\controller Specifies the CMS controller
|
||||
*/
|
||||
public $controller;
|
||||
|
||||
/**
|
||||
* Creates the object instance.
|
||||
* @param \Cms\Classes\Page $page Specifies the CMS page.
|
||||
* @param \Cms\Classes\Layout $layout Specifies the CMS layout.
|
||||
* @param \Cms\Classes\Controller $controller Specifies the CMS controller.
|
||||
*/
|
||||
public function __construct($page, $layout, $controller)
|
||||
{
|
||||
$this->page = $page;
|
||||
$this->layout = $layout;
|
||||
$this->controller = $controller;
|
||||
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* This event is triggered when all components are initialized and before AJAX is handled.
|
||||
* The layout's onInit method triggers before the page's onInit method.
|
||||
*/
|
||||
public function onInit()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* This event is triggered in the beginning of the execution cycle.
|
||||
* The layout's onStart method triggers before the page's onStart method.
|
||||
*/
|
||||
public function onStart()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* This event is triggered in the end of the execution cycle, but before the page is displayed.
|
||||
* The layout's onEnd method triggers after the page's onEnd method.
|
||||
*/
|
||||
public function onEnd()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* ArrayAccess implementation
|
||||
*/
|
||||
public function offsetSet($offset, $value): void
|
||||
{
|
||||
$this->controller->vars[$offset] = $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* ArrayAccess implementation
|
||||
*/
|
||||
public function offsetExists($offset): bool
|
||||
{
|
||||
return isset($this->controller->vars[$offset]);
|
||||
}
|
||||
|
||||
/**
|
||||
* ArrayAccess implementation
|
||||
*/
|
||||
public function offsetUnset($offset): void
|
||||
{
|
||||
unset($this->controller->vars[$offset]);
|
||||
}
|
||||
|
||||
/**
|
||||
* ArrayAccess implementation
|
||||
*/
|
||||
public function offsetGet($offset): mixed
|
||||
{
|
||||
return $this->controller->vars[$offset] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamically handle calls into the controller instance.
|
||||
* @param string $method
|
||||
* @param array $parameters
|
||||
* @return mixed
|
||||
*/
|
||||
public function __call($method, $parameters)
|
||||
{
|
||||
if ($this->methodExists($method)) {
|
||||
return call_user_func_array([$this, $method], $parameters);
|
||||
}
|
||||
|
||||
return call_user_func_array([$this->controller, $method], $parameters);
|
||||
}
|
||||
|
||||
/**
|
||||
* This object is referenced as $this->page in Cms\Classes\ComponentBase,
|
||||
* so to avoid $this->page->page this method will proxy there. This is also
|
||||
* used as a helper for accessing controller variables/components easier
|
||||
* in the page code, eg. $this->foo instead of $this['foo']
|
||||
* @param string $name
|
||||
* @return void
|
||||
*/
|
||||
public function __get($name)
|
||||
{
|
||||
if (isset($this->page->components[$name]) || isset($this->layout->components[$name])) {
|
||||
return $this[$name];
|
||||
}
|
||||
|
||||
if (($value = $this->page->{$name}) !== null) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if (array_key_exists($name, $this->controller->vars)) {
|
||||
return $this[$name];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* This will set a property on the CMS Page object.
|
||||
* @param string $name
|
||||
* @param mixed $value
|
||||
* @return void
|
||||
*/
|
||||
public function __set($name, $value)
|
||||
{
|
||||
return $this->page->{$name} = $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* This will check if a property is set on the CMS Page object.
|
||||
* @param string $name
|
||||
* @return bool
|
||||
*/
|
||||
public function __isset($name)
|
||||
{
|
||||
if (isset($this->page->components[$name]) || isset($this->layout->components[$name])) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isset($this->page->{$name})) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return array_key_exists($name, $this->controller->vars);
|
||||
}
|
||||
}
|
||||
382
modules/cms/classes/CodeParser.php
Normal file
382
modules/cms/classes/CodeParser.php
Normal file
@@ -0,0 +1,382 @@
|
||||
<?php namespace Cms\Classes;
|
||||
|
||||
use File;
|
||||
use Lang;
|
||||
use Cache;
|
||||
use Config;
|
||||
use SystemException;
|
||||
use Winter\Storm\Support\Str;
|
||||
|
||||
/**
|
||||
* Parses the PHP code section of CMS objects.
|
||||
*
|
||||
* @package winter\wn-cms-module
|
||||
* @author Alexey Bobkov, Samuel Georges
|
||||
*/
|
||||
class CodeParser
|
||||
{
|
||||
/**
|
||||
* @var \Cms\Classes\CmsCompoundObject A reference to the CMS object being parsed.
|
||||
*/
|
||||
protected $object;
|
||||
|
||||
/**
|
||||
* @var string Contains a path to the CMS object's file being parsed.
|
||||
*/
|
||||
protected $filePath;
|
||||
|
||||
/**
|
||||
* @var mixed The internal cache, keeps parsed object information during a request.
|
||||
*/
|
||||
protected static $cache = [];
|
||||
|
||||
/**
|
||||
* @var string Key for the parsed PHP file information cache.
|
||||
*/
|
||||
protected $dataCacheKey = '';
|
||||
|
||||
/**
|
||||
* Creates the class instance
|
||||
* @param \Cms\Classes\CmsCompoundObject A reference to a CMS object to parse.
|
||||
*/
|
||||
public function __construct(CmsCompoundObject $object)
|
||||
{
|
||||
$this->object = $object;
|
||||
$this->filePath = $object->getFilePath();
|
||||
$this->dataCacheKey = Config::get('cache.codeParserDataCacheKey', 'cms-php-file-data');
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the CMS object's PHP code section and returns an array with the following keys:
|
||||
* - className
|
||||
* - filePath (path to the parsed PHP file)
|
||||
* - offset (PHP section offset in the template file)
|
||||
* - source ('parser', 'request-cache', or 'cache')
|
||||
* @return array
|
||||
*/
|
||||
public function parse()
|
||||
{
|
||||
/*
|
||||
* If the object has already been parsed in this request return the cached data.
|
||||
*/
|
||||
if (array_key_exists($this->filePath, self::$cache)) {
|
||||
self::$cache[$this->filePath]['source'] = 'request-cache';
|
||||
return self::$cache[$this->filePath];
|
||||
}
|
||||
|
||||
/*
|
||||
* Try to load the parsed data from the cache
|
||||
*/
|
||||
$path = $this->getCacheFilePath();
|
||||
|
||||
$result = [
|
||||
'filePath' => $path,
|
||||
'className' => null,
|
||||
'source' => null,
|
||||
'offset' => 0
|
||||
];
|
||||
|
||||
/*
|
||||
* There are two types of possible caching scenarios, either stored
|
||||
* in the cache itself, or stored as a cache file. In both cases,
|
||||
* make sure the cache is not stale and use it.
|
||||
*/
|
||||
if (is_file($path)) {
|
||||
$cachedInfo = $this->getCachedFileInfo();
|
||||
$hasCache = $cachedInfo !== null;
|
||||
|
||||
/*
|
||||
* Valid cache, return result
|
||||
*/
|
||||
if ($hasCache && $cachedInfo['mtime'] == $this->object->mtime) {
|
||||
$result['className'] = $cachedInfo['className'];
|
||||
$result['source'] = 'cache';
|
||||
|
||||
return self::$cache[$this->filePath] = $result;
|
||||
}
|
||||
|
||||
/*
|
||||
* Cache expired, cache file not stale, refresh cache and return result
|
||||
*/
|
||||
if (!$hasCache && filemtime($path) >= $this->object->mtime) {
|
||||
$className = $this->extractClassFromFile($path);
|
||||
if ($className) {
|
||||
$result['className'] = $className;
|
||||
$result['source'] = 'file-cache';
|
||||
|
||||
$this->storeCachedInfo($result);
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$result['className'] = $this->rebuild($path);
|
||||
$result['source'] = 'parser';
|
||||
|
||||
$this->storeCachedInfo($result);
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuilds the current file cache.
|
||||
* @param string The path in which the cached file should be stored
|
||||
*/
|
||||
protected function rebuild($path)
|
||||
{
|
||||
$uniqueName = str_replace('.', '', uniqid('', true)).'_'.md5(mt_rand());
|
||||
$className = 'Cms'.$uniqueName.'Class';
|
||||
|
||||
$body = $this->object->code;
|
||||
$body = preg_replace('/^\s*function/m', 'public function', $body);
|
||||
|
||||
$namespaces = [];
|
||||
$pattern = '/(use\s+[a-z0-9_\\\\]+(\s+as\s+[a-z0-9_]+)?;(\r\n|\n)?)/mi';
|
||||
preg_match_all($pattern, $body, $namespaces);
|
||||
$body = preg_replace($pattern, '', $body);
|
||||
|
||||
$parentClass = $this->object->getCodeClassParent();
|
||||
if ($parentClass !== null) {
|
||||
$parentClass = ' extends '.$parentClass;
|
||||
}
|
||||
|
||||
$fileContents = '<?php '.PHP_EOL;
|
||||
|
||||
foreach ($namespaces[0] as $namespace) {
|
||||
// Only allow compound or aliased use statements
|
||||
if (str_contains($namespace, '\\') || str_contains($namespace, ' as ')) {
|
||||
$fileContents .= trim($namespace).PHP_EOL;
|
||||
}
|
||||
}
|
||||
|
||||
$fileContents .= 'class '.$className.$parentClass.PHP_EOL;
|
||||
$fileContents .= '{'.PHP_EOL;
|
||||
$fileContents .= trim($body).PHP_EOL;
|
||||
$fileContents .= '}'.PHP_EOL;
|
||||
|
||||
$this->makeDirectorySafe(dirname($path));
|
||||
|
||||
$this->writeContentSafe($path, $fileContents);
|
||||
|
||||
// Attempt to load the generated code file to ensure any errors are thrown
|
||||
// before the file is cached
|
||||
if (!class_exists($className)) {
|
||||
require_once $path;
|
||||
}
|
||||
|
||||
return $className;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the object's PHP file and returns the corresponding object.
|
||||
* @param \Cms\Classes\Page $page Specifies the CMS page.
|
||||
* @param \Cms\Classes\Layout $layout Specifies the CMS layout.
|
||||
* @param \Cms\Classes\Controller $controller Specifies the CMS controller.
|
||||
* @return mixed
|
||||
*/
|
||||
public function source($page, $layout, $controller)
|
||||
{
|
||||
$data = $this->parse();
|
||||
$className = $data['className'];
|
||||
|
||||
if (!class_exists($className)) {
|
||||
require_once $data['filePath'];
|
||||
}
|
||||
|
||||
if (!class_exists($className) && ($data = $this->handleCorruptCache($data))) {
|
||||
$className = $data['className'];
|
||||
}
|
||||
|
||||
return new $className($page, $layout, $controller);
|
||||
}
|
||||
|
||||
/**
|
||||
* In some rare cases the cache file will not contain the class
|
||||
* name we expect. When this happens, destroy the corrupt file,
|
||||
* flush the request cache, and repeat the cycle.
|
||||
* @return void
|
||||
*/
|
||||
protected function handleCorruptCache($data)
|
||||
{
|
||||
$path = array_get($data, 'filePath', $this->getCacheFilePath());
|
||||
|
||||
if (is_file($path)) {
|
||||
if (($className = $this->extractClassFromFile($path)) && class_exists($className)) {
|
||||
$data['className'] = $className;
|
||||
return $data;
|
||||
}
|
||||
|
||||
@unlink($path);
|
||||
}
|
||||
|
||||
unset(self::$cache[$this->filePath]);
|
||||
|
||||
return $this->parse();
|
||||
}
|
||||
|
||||
//
|
||||
// Cache
|
||||
//
|
||||
|
||||
/**
|
||||
* Stores result data inside cache.
|
||||
* @param array $result
|
||||
* @return void
|
||||
*/
|
||||
protected function storeCachedInfo($result)
|
||||
{
|
||||
$cacheItem = $result;
|
||||
$cacheItem['mtime'] = $this->object->mtime;
|
||||
|
||||
$cached = $this->getCachedInfo() ?: [];
|
||||
$cached[$this->filePath] = $cacheItem;
|
||||
|
||||
$expiresAt = now()->addMinutes(1440);
|
||||
Cache::put($this->dataCacheKey, base64_encode(serialize($cached)), $expiresAt);
|
||||
|
||||
self::$cache[$this->filePath] = $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns path to the cached parsed file
|
||||
*/
|
||||
protected function getCacheFilePath(): string
|
||||
{
|
||||
$pathSegments = [
|
||||
storage_path('cms' . DIRECTORY_SEPARATOR . 'cache'),
|
||||
trim(
|
||||
Str::after(
|
||||
pathinfo($this->filePath, PATHINFO_DIRNAME),
|
||||
base_path()
|
||||
),
|
||||
DIRECTORY_SEPARATOR
|
||||
),
|
||||
basename($this->filePath) . '.php',
|
||||
];
|
||||
|
||||
return implode(DIRECTORY_SEPARATOR, $pathSegments);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns information about all cached files.
|
||||
* @return mixed Returns an array representing the cached data or NULL.
|
||||
*/
|
||||
protected function getCachedInfo()
|
||||
{
|
||||
$cached = Cache::get($this->dataCacheKey, false);
|
||||
|
||||
if (
|
||||
$cached !== false &&
|
||||
($cached = @unserialize(@base64_decode($cached))) !== false
|
||||
) {
|
||||
return $cached;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns information about a cached file
|
||||
* @return integer
|
||||
*/
|
||||
protected function getCachedFileInfo()
|
||||
{
|
||||
$cached = $this->getCachedInfo();
|
||||
|
||||
if ($cached !== null && array_key_exists($this->filePath, $cached)) {
|
||||
return $cached[$this->filePath];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
//
|
||||
// Helpers
|
||||
//
|
||||
|
||||
/**
|
||||
* Extracts the class name from a cache file
|
||||
* @return string
|
||||
*/
|
||||
protected function extractClassFromFile($path)
|
||||
{
|
||||
$fileContent = file_get_contents($path);
|
||||
$matches = [];
|
||||
$pattern = '/Cms\S+_\S+Class/';
|
||||
preg_match($pattern, $fileContent, $matches);
|
||||
|
||||
if (!empty($matches[0])) {
|
||||
return $matches[0];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes content with concurrency support and cache busting
|
||||
* This work is based on the Twig\Cache\FilesystemCache class
|
||||
*/
|
||||
protected function writeContentSafe($path, $content)
|
||||
{
|
||||
$count = 0;
|
||||
$tmpFile = tempnam(dirname($path), basename($path));
|
||||
|
||||
if (@file_put_contents($tmpFile, $content) === false) {
|
||||
throw new SystemException(Lang::get('system::lang.file.create_fail', ['name'=>$tmpFile]));
|
||||
}
|
||||
|
||||
while (!@rename($tmpFile, $path)) {
|
||||
usleep(rand(50000, 200000));
|
||||
|
||||
if ($count++ > 10) {
|
||||
throw new SystemException(Lang::get('system::lang.file.create_fail', ['name'=>$path]));
|
||||
}
|
||||
}
|
||||
|
||||
File::chmod($path);
|
||||
|
||||
/*
|
||||
* Compile cached file into bytecode cache
|
||||
*/
|
||||
if (Config::get('cms.forceBytecodeInvalidation', false)) {
|
||||
$opcache_enabled = ini_get('opcache.enable');
|
||||
$opcache_path = trim(ini_get('opcache.restrict_api'));
|
||||
|
||||
if (!empty($opcache_path) && !starts_with(__FILE__, $opcache_path)) {
|
||||
$opcache_enabled = false;
|
||||
}
|
||||
|
||||
if (function_exists('opcache_invalidate') && $opcache_enabled) {
|
||||
opcache_invalidate($path, true);
|
||||
}
|
||||
elseif (function_exists('apc_compile_file')) {
|
||||
apc_compile_file($path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Make directory with concurrency support
|
||||
*/
|
||||
protected function makeDirectorySafe($dir)
|
||||
{
|
||||
$count = 0;
|
||||
|
||||
if (is_dir($dir)) {
|
||||
if (!is_writable($dir)) {
|
||||
throw new SystemException(Lang::get('system::lang.directory.create_fail', ['name'=>$dir]));
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
while (!is_dir($dir) && !@mkdir($dir, 0777, true)) {
|
||||
usleep(rand(50000, 200000));
|
||||
|
||||
if ($count++ > 10) {
|
||||
throw new SystemException(Lang::get('system::lang.directory.create_fail', ['name'=>$dir]));
|
||||
}
|
||||
}
|
||||
|
||||
File::chmodRecursive($dir);
|
||||
}
|
||||
}
|
||||
336
modules/cms/classes/ComponentBase.php
Normal file
336
modules/cms/classes/ComponentBase.php
Normal file
@@ -0,0 +1,336 @@
|
||||
<?php namespace Cms\Classes;
|
||||
|
||||
use Str;
|
||||
use Lang;
|
||||
use Config;
|
||||
use Winter\Storm\Extension\Extendable;
|
||||
use BadMethodCallException;
|
||||
|
||||
/**
|
||||
* Component base class
|
||||
*
|
||||
* @package winter\wn-cms-module
|
||||
* @author Alexey Bobkov, Samuel Georges
|
||||
*/
|
||||
abstract class ComponentBase extends Extendable
|
||||
{
|
||||
use \System\Traits\AssetMaker;
|
||||
use \System\Traits\EventEmitter;
|
||||
use \System\Traits\PropertyContainer;
|
||||
|
||||
/**
|
||||
* @var string A unique identifier for this component.
|
||||
*/
|
||||
public $id;
|
||||
|
||||
/**
|
||||
* @var string Alias used for this component.
|
||||
*/
|
||||
public $alias;
|
||||
|
||||
/**
|
||||
* @var string Component class name or class alias used in the component declaration in a template.
|
||||
*/
|
||||
public $name;
|
||||
|
||||
/**
|
||||
* @var boolean Determines whether the component is hidden from the back-end UI.
|
||||
*/
|
||||
public $isHidden = false;
|
||||
|
||||
/**
|
||||
* @var string Icon of the plugin that defines the component.
|
||||
* This field is used by the CMS internally.
|
||||
*/
|
||||
public $pluginIcon;
|
||||
|
||||
/**
|
||||
* @var string Component CSS class name for the back-end page/layout component list.
|
||||
* This field is used by the CMS internally.
|
||||
*/
|
||||
public $componentCssClass;
|
||||
|
||||
/**
|
||||
* @var boolean Determines whether Inspector can be used with the component.
|
||||
* This field is used by the CMS internally.
|
||||
*/
|
||||
public $inspectorEnabled = true;
|
||||
|
||||
/**
|
||||
* @var string Specifies the component directory name.
|
||||
*/
|
||||
protected $dirName;
|
||||
|
||||
/**
|
||||
* @var \Cms\Classes\Controller Controller object.
|
||||
*/
|
||||
protected $controller;
|
||||
|
||||
/**
|
||||
* @var \Cms\Classes\PageCode Page object object.
|
||||
*/
|
||||
protected $page;
|
||||
|
||||
/**
|
||||
* @var array A collection of external property names used by this component.
|
||||
*/
|
||||
protected $externalPropertyNames = [];
|
||||
|
||||
/**
|
||||
* Component constructor. Takes in the page or layout code section object
|
||||
* and properties set by the page or layout.
|
||||
* @param null|CodeBase $cmsObject
|
||||
* @param array $properties
|
||||
*/
|
||||
public function __construct(?CodeBase $cmsObject = null, $properties = [])
|
||||
{
|
||||
if ($cmsObject !== null) {
|
||||
$this->page = $cmsObject;
|
||||
$this->controller = $cmsObject->controller;
|
||||
}
|
||||
|
||||
$this->properties = $this->validateProperties($properties);
|
||||
|
||||
$className = Str::normalizeClassName(get_called_class());
|
||||
$this->dirName = strtolower(str_replace('\\', '/', $className));
|
||||
$this->assetPath = Config::get('cms.pluginsPath', '/plugins').dirname(dirname($this->dirName));
|
||||
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns information about this component.
|
||||
*
|
||||
* This method must be defined in your component and, at a minimum, should return an array with two keys:
|
||||
*
|
||||
* - `name`: The name of your component.
|
||||
* - `description`: The description or purpose of your component.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
abstract public function componentDetails();
|
||||
|
||||
/**
|
||||
* Returns the absolute component path.
|
||||
*/
|
||||
public function getPath()
|
||||
{
|
||||
return plugins_path() . $this->dirName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Executed when this component is first initialized, before AJAX requests.
|
||||
*/
|
||||
public function init()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Executed when this component is bound to a page or layout, part of
|
||||
* the page life cycle.
|
||||
*/
|
||||
public function onRun()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Executed when this component is rendered on a page or layout.
|
||||
*/
|
||||
public function onRender()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a requested partial in context of this component,
|
||||
* see Cms\Classes\Controller@renderPartial for usage.
|
||||
*/
|
||||
public function renderPartial()
|
||||
{
|
||||
$this->controller->setComponentContext($this);
|
||||
$result = call_user_func_array([$this->controller, 'renderPartial'], func_get_args());
|
||||
$this->controller->setComponentContext(null);
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes the event cycle when running an AJAX handler.
|
||||
* @return boolean Returns true if the handler was found. Returns false otherwise.
|
||||
*/
|
||||
public function runAjaxHandler($handler)
|
||||
{
|
||||
/**
|
||||
* @event cms.component.beforeRunAjaxHandler
|
||||
* Provides an opportunity to modify an AJAX request to a component before it is processed by the component
|
||||
*
|
||||
* The parameter provided is `$handler` (the requested AJAX handler to be run)
|
||||
*
|
||||
* Example usage (forwards AJAX handlers to a backend widget):
|
||||
*
|
||||
* Event::listen('cms.component.beforeRunAjaxHandler', function ((\Cms\Classes\ComponentBase) $component, (string) $handler) {
|
||||
* if (strpos($handler, '::')) {
|
||||
* list($componentAlias, $handlerName) = explode('::', $handler);
|
||||
* if ($componentAlias === $this->getBackendWidgetAlias()) {
|
||||
* return $this->backendControllerProxy->runAjaxHandler($handler);
|
||||
* }
|
||||
* }
|
||||
* });
|
||||
*
|
||||
* Or
|
||||
*
|
||||
* $this->controller->bindEvent('component.beforeRunAjaxHandler', function ((string) $handler) {
|
||||
* if (strpos($handler, '::')) {
|
||||
* list($componentAlias, $handlerName) = explode('::', $handler);
|
||||
* if ($componentAlias === $this->getBackendWidgetAlias()) {
|
||||
* return $this->backendControllerProxy->runAjaxHandler($handler);
|
||||
* }
|
||||
* }
|
||||
* });
|
||||
*
|
||||
*/
|
||||
if ($event = $this->fireSystemEvent('cms.component.beforeRunAjaxHandler', [$handler])) {
|
||||
return $event;
|
||||
}
|
||||
|
||||
$result = $this->$handler();
|
||||
|
||||
/**
|
||||
* @event cms.component.runAjaxHandler
|
||||
* Provides an opportunity to modify an AJAX request to a component after it is processed by the component
|
||||
*
|
||||
* The parameters provided are `$handler` (the requested AJAX handler to be run) and `$result` (the result of the component processing the request)
|
||||
*
|
||||
* Example usage (Logs requests and their response):
|
||||
*
|
||||
* Event::listen('cms.component.beforeRunHandler', function ((\Cms\Classes\ComponentBase) $component, (string) $handler, (mixed) $result) {
|
||||
* if (in_array($handler, $interceptHandlers)) {
|
||||
* return 'request has been intercepted, original response: ' . json_encode($result);
|
||||
* }
|
||||
* });
|
||||
*
|
||||
* Or
|
||||
*
|
||||
* $this->controller->bindEvent('componenet.beforeRunAjaxHandler', function ((string) $handler, (mixed) $result) {
|
||||
* if (in_array($handler, $interceptHandlers)) {
|
||||
* return 'request has been intercepted, original response: ' . json_encode($result);
|
||||
* }
|
||||
* });
|
||||
*
|
||||
*/
|
||||
if ($event = $this->fireSystemEvent('cms.component.runAjaxHandler', [$handler, $result])) {
|
||||
return $event;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
//
|
||||
// External properties
|
||||
//
|
||||
|
||||
/*
|
||||
* Description on how to access external property names.
|
||||
*
|
||||
* # When
|
||||
* pageNumber = "7"
|
||||
* $this->propertyName('pageNumber'); // Returns NULL
|
||||
* $this->paramName('pageNumber'); // Returns NULL
|
||||
*
|
||||
* # When
|
||||
* pageNumber = "{{ :page }}"
|
||||
*
|
||||
* $this->propertyName('pageNumber'); // Returns ":page"
|
||||
* $this->paramName('pageNumber'); // Returns "page"
|
||||
*
|
||||
* # When
|
||||
* pageNumber = "{{ page }}"
|
||||
*
|
||||
* $this->propertyName('pageNumber'); // Returns "page"
|
||||
* $this->paramName('pageNumber'); // Returns NULL
|
||||
*/
|
||||
|
||||
/**
|
||||
* Sets names used by external properties.
|
||||
* @param array $names The key should be the property name,
|
||||
* the value should be the external property name.
|
||||
* @return void
|
||||
*/
|
||||
public function setExternalPropertyNames(array $names)
|
||||
{
|
||||
$this->externalPropertyNames = $names;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets an external property name.
|
||||
* @param string $name Property name
|
||||
* @param string $extName External property name
|
||||
* @return string
|
||||
*/
|
||||
public function setExternalPropertyName($name, $extName)
|
||||
{
|
||||
return $this->externalPropertyNames[$name] = $extName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the external property name when the property value is an external property reference.
|
||||
* Otherwise the default value specified is returned.
|
||||
* @param string $name The property name
|
||||
* @param mixed $default
|
||||
* @return string
|
||||
*/
|
||||
public function propertyName($name, $default = null)
|
||||
{
|
||||
return array_get($this->externalPropertyNames, $name, $default);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the external property name when the property value is a routing parameter reference.
|
||||
* Otherwise the default value specified is returned.
|
||||
* @param string $name The property name
|
||||
* @param mixed $default
|
||||
* @return string
|
||||
*/
|
||||
public function paramName($name, $default = null)
|
||||
{
|
||||
if (($extName = $this->propertyName($name)) && substr($extName, 0, 1) == ':') {
|
||||
return substr($extName, 1);
|
||||
}
|
||||
|
||||
return $default;
|
||||
}
|
||||
|
||||
//
|
||||
// Magic methods
|
||||
//
|
||||
|
||||
/**
|
||||
* Dynamically handle calls into the controller instance.
|
||||
* @param string $method
|
||||
* @param array $parameters
|
||||
* @return mixed
|
||||
*/
|
||||
public function __call($method, $parameters)
|
||||
{
|
||||
try {
|
||||
return parent::__call($method, $parameters);
|
||||
}
|
||||
catch (BadMethodCallException $ex) {
|
||||
}
|
||||
|
||||
if (isset($this->controller) && method_exists($this->controller, $method)) {
|
||||
return call_user_func_array([$this->controller, $method], $parameters);
|
||||
}
|
||||
|
||||
throw new BadMethodCallException(Lang::get('cms::lang.component.method_not_found', [
|
||||
'name' => get_class($this),
|
||||
'method' => $method
|
||||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the component's alias, used by __SELF__
|
||||
*/
|
||||
public function __toString()
|
||||
{
|
||||
return $this->alias;
|
||||
}
|
||||
}
|
||||
129
modules/cms/classes/ComponentHelpers.php
Normal file
129
modules/cms/classes/ComponentHelpers.php
Normal file
@@ -0,0 +1,129 @@
|
||||
<?php namespace Cms\Classes;
|
||||
|
||||
use Lang;
|
||||
|
||||
/**
|
||||
* Defines some component helpers for the CMS UI.
|
||||
*
|
||||
* @package winter\wn-system-module
|
||||
* @author Alexey Bobkov, Samuel Georges
|
||||
*/
|
||||
class ComponentHelpers
|
||||
{
|
||||
/**
|
||||
* Returns a component property configuration as a JSON string or array.
|
||||
* @param mixed $component The component object
|
||||
* @param boolean $addAliasProperty Determines if the Alias property should be added to the result.
|
||||
* @param boolean $returnArray Determines if the method should return an array.
|
||||
* @return string
|
||||
*/
|
||||
public static function getComponentsPropertyConfig($component, $addAliasProperty = true, $returnArray = false)
|
||||
{
|
||||
$result = [];
|
||||
|
||||
if ($addAliasProperty) {
|
||||
$property = [
|
||||
'property' => 'oc.alias',
|
||||
'title' => Lang::get('cms::lang.component.alias'),
|
||||
'description' => Lang::get('cms::lang.component.alias_description'),
|
||||
'type' => 'string',
|
||||
'validationPattern' => '^(@)?[a-zA-Z]+[0-9a-z\_]*$',
|
||||
'validationMessage' => Lang::get('cms::lang.component.validation_message'),
|
||||
'required' => true,
|
||||
'showExternalParam' => false
|
||||
];
|
||||
$result[] = $property;
|
||||
}
|
||||
|
||||
$properties = $component->defineProperties();
|
||||
if (is_array($properties)) {
|
||||
foreach ($properties as $name => $params) {
|
||||
$property = [
|
||||
'property' => $name,
|
||||
'title' => array_get($params, 'title', $name),
|
||||
'type' => array_get($params, 'type', 'string'),
|
||||
'showExternalParam' => array_get($params, 'showExternalParam', true)
|
||||
];
|
||||
|
||||
foreach ($params as $name => $value) {
|
||||
if (isset($property[$name])) {
|
||||
continue;
|
||||
}
|
||||
$property[$name] = $value;
|
||||
}
|
||||
|
||||
/*
|
||||
* Translate human values
|
||||
*/
|
||||
$translate = ['title', 'description', 'options', 'group', 'validationMessage'];
|
||||
foreach ($property as $name => $value) {
|
||||
if (!in_array($name, $translate)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (is_array($value)) {
|
||||
array_walk($property[$name], function (&$_value, $key) {
|
||||
$_value = Lang::get($_value);
|
||||
});
|
||||
}
|
||||
else {
|
||||
$property[$name] = Lang::get($value);
|
||||
}
|
||||
}
|
||||
|
||||
$result[] = $property;
|
||||
}
|
||||
}
|
||||
|
||||
if ($returnArray) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
return json_encode($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a component property values.
|
||||
* @param mixed $component The component object
|
||||
* @return mixed
|
||||
*/
|
||||
public static function getComponentPropertyValues($component)
|
||||
{
|
||||
$result = [];
|
||||
|
||||
$result['oc.alias'] = $component->alias;
|
||||
|
||||
$properties = $component->defineProperties();
|
||||
foreach ($properties as $name => $params) {
|
||||
$result[$name] = $component->property($name);
|
||||
}
|
||||
|
||||
return json_encode($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a component name.
|
||||
* @param mixed $component The component object
|
||||
* @return string
|
||||
*/
|
||||
public static function getComponentName($component)
|
||||
{
|
||||
$details = $component->componentDetails();
|
||||
$name = $details['name'] ?? 'cms::lang.component.unnamed';
|
||||
|
||||
return Lang::get($name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a component description.
|
||||
* @param mixed $component The component object
|
||||
* @return string
|
||||
*/
|
||||
public static function getComponentDescription($component)
|
||||
{
|
||||
$details = $component->componentDetails();
|
||||
$name = $details['description'] ?? 'cms::lang.component.no_description';
|
||||
|
||||
return Lang::get($name);
|
||||
}
|
||||
}
|
||||
239
modules/cms/classes/ComponentManager.php
Normal file
239
modules/cms/classes/ComponentManager.php
Normal file
@@ -0,0 +1,239 @@
|
||||
<?php namespace Cms\Classes;
|
||||
|
||||
use Str;
|
||||
use System\Classes\PluginManager;
|
||||
use SystemException;
|
||||
use Illuminate\Support\Facades\App;
|
||||
|
||||
/**
|
||||
* Component manager
|
||||
*
|
||||
* @package winter\wn-cms-module
|
||||
* @author Alexey Bobkov, Samuel Georges
|
||||
*/
|
||||
class ComponentManager
|
||||
{
|
||||
use \Winter\Storm\Support\Traits\Singleton;
|
||||
|
||||
/**
|
||||
* @var array Cache of registration callbacks.
|
||||
*/
|
||||
protected $callbacks = [];
|
||||
|
||||
/**
|
||||
* @var array An array where keys are codes and values are class names.
|
||||
*/
|
||||
protected $codeMap;
|
||||
|
||||
/**
|
||||
* @var array An array where keys are class names and values are codes.
|
||||
*/
|
||||
protected $classMap;
|
||||
|
||||
/**
|
||||
* @var array An array containing references to a corresponding plugin for each component class.
|
||||
*/
|
||||
protected $pluginMap;
|
||||
|
||||
/**
|
||||
* @var array A cached array of component details.
|
||||
*/
|
||||
protected $detailsCache;
|
||||
|
||||
/**
|
||||
* Scans each plugin an loads it's components.
|
||||
* @return void
|
||||
*/
|
||||
protected function loadComponents()
|
||||
{
|
||||
/*
|
||||
* Load module components
|
||||
*/
|
||||
foreach ($this->callbacks as $callback) {
|
||||
$callback($this);
|
||||
}
|
||||
|
||||
/*
|
||||
* Load plugin components
|
||||
*/
|
||||
$pluginManager = PluginManager::instance();
|
||||
$plugins = $pluginManager->getPlugins();
|
||||
|
||||
foreach ($plugins as $plugin) {
|
||||
$components = $plugin->registerComponents();
|
||||
if (!is_array($components)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($components as $className => $code) {
|
||||
$this->registerComponent($className, $code, $plugin);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually registers a component for consideration. Usage:
|
||||
*
|
||||
* ComponentManager::registerComponents(function ($manager) {
|
||||
* $manager->registerComponent('Winter\Demo\Components\Test', 'testComponent');
|
||||
* });
|
||||
*
|
||||
* @param callable $definitions
|
||||
* @return array Array values are class names.
|
||||
*/
|
||||
public function registerComponents(callable $definitions)
|
||||
{
|
||||
$this->callbacks[] = $definitions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a single component.
|
||||
*/
|
||||
public function registerComponent($className, $code = null, $plugin = null)
|
||||
{
|
||||
if (!$this->classMap) {
|
||||
$this->classMap = [];
|
||||
}
|
||||
|
||||
if (!$this->codeMap) {
|
||||
$this->codeMap = [];
|
||||
}
|
||||
|
||||
if (!$code) {
|
||||
$code = Str::getClassId($className);
|
||||
}
|
||||
|
||||
if ($code == 'viewBag' && $className != 'Cms\Components\ViewBag') {
|
||||
throw new SystemException(sprintf(
|
||||
'The component code viewBag is reserved. Please use another code for the component class %s.',
|
||||
$className
|
||||
));
|
||||
}
|
||||
|
||||
$className = Str::normalizeClassName($className);
|
||||
$this->codeMap[$code] = $className;
|
||||
$this->classMap[$className] = $code;
|
||||
if ($plugin !== null) {
|
||||
$this->pluginMap[$className] = $plugin;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of registered components.
|
||||
* @return array Array keys are codes, values are class names.
|
||||
*/
|
||||
public function listComponents()
|
||||
{
|
||||
if ($this->codeMap === null) {
|
||||
$this->loadComponents();
|
||||
}
|
||||
|
||||
return $this->codeMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array of all component detail definitions.
|
||||
* @return array Array keys are component codes, values are the details defined in the component.
|
||||
*/
|
||||
public function listComponentDetails()
|
||||
{
|
||||
if ($this->detailsCache !== null) {
|
||||
return $this->detailsCache;
|
||||
}
|
||||
|
||||
$details = [];
|
||||
foreach ($this->listComponents() as $componentAlias => $componentClass) {
|
||||
$details[$componentAlias] = $this->makeComponent($componentClass)->componentDetails();
|
||||
}
|
||||
|
||||
return $this->detailsCache = $details;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a class name from a component code
|
||||
* Normalizes a class name or converts an code to it's class name.
|
||||
* @return string The class name resolved, or null.
|
||||
*/
|
||||
public function resolve($name)
|
||||
{
|
||||
$codes = $this->listComponents();
|
||||
|
||||
if (isset($codes[$name])) {
|
||||
return $codes[$name];
|
||||
}
|
||||
|
||||
$name = Str::normalizeClassName($name);
|
||||
if (isset($this->classMap[$name])) {
|
||||
return $name;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks to see if a component has been registered.
|
||||
* @param string $name A component class name or code.
|
||||
* @return bool Returns true if the component is registered, otherwise false.
|
||||
*/
|
||||
public function hasComponent($name)
|
||||
{
|
||||
$className = $this->resolve($name);
|
||||
if (!$className) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isset($this->classMap[$className]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes a component object with properties set.
|
||||
*
|
||||
* @param string $name A component class name or code.
|
||||
* @param CmsObject $cmsObject The Cms object that spawned this component.
|
||||
* @param array $properties The properties set by the Page or Layout.
|
||||
* @param bool $isSoftComponent Defines if this is a soft component.
|
||||
*
|
||||
* @return ComponentBase The component object.
|
||||
* @throws SystemException If the (hard) component cannot be found or is not registered.
|
||||
*/
|
||||
public function makeComponent($name, $cmsObject = null, $properties = [], $isSoftComponent = false)
|
||||
{
|
||||
$className = $this->resolve(ltrim($name, '@'));
|
||||
|
||||
if (!$className && !$isSoftComponent) {
|
||||
throw new SystemException(sprintf(
|
||||
'Class name is not registered for the component "%s". Check the component plugin.',
|
||||
$name
|
||||
));
|
||||
}
|
||||
|
||||
if (!class_exists($className) && !$isSoftComponent) {
|
||||
throw new SystemException(sprintf(
|
||||
'Component class not found "%s". Check the component plugin.',
|
||||
$className
|
||||
));
|
||||
}
|
||||
|
||||
if (class_exists($className)) {
|
||||
$component = App::make($className, ['cmsObject' => $cmsObject, 'properties' => $properties]);
|
||||
$component->name = $name;
|
||||
|
||||
return $component;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a parent plugin for a specific component object.
|
||||
* @param mixed $component A component to find the plugin for.
|
||||
* @return mixed Returns the plugin object or null.
|
||||
*/
|
||||
public function findComponentPlugin($component)
|
||||
{
|
||||
$className = Str::normalizeClassName(get_class($component));
|
||||
if (isset($this->pluginMap[$className])) {
|
||||
return $this->pluginMap[$className];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
262
modules/cms/classes/ComponentPartial.php
Normal file
262
modules/cms/classes/ComponentPartial.php
Normal file
@@ -0,0 +1,262 @@
|
||||
<?php namespace Cms\Classes;
|
||||
|
||||
use File;
|
||||
use Lang;
|
||||
use Cms\Contracts\CmsObject as CmsObjectContract;
|
||||
use Cms\Helpers\File as FileHelper;
|
||||
use Winter\Storm\Extension\Extendable;
|
||||
use ApplicationException;
|
||||
|
||||
/**
|
||||
* The CMS component partial class. These objects are read-only.
|
||||
*
|
||||
* @package winter\wn-cms-module
|
||||
* @author Alexey Bobkov, Samuel Georges
|
||||
*/
|
||||
class ComponentPartial extends Extendable implements CmsObjectContract
|
||||
{
|
||||
/**
|
||||
* @var \Cms\Classes\ComponentBase A reference to the CMS component containing the object.
|
||||
*/
|
||||
protected $component;
|
||||
|
||||
/**
|
||||
* @var string The component partial file name.
|
||||
*/
|
||||
public $fileName;
|
||||
|
||||
/**
|
||||
* @var string Last modified time.
|
||||
*/
|
||||
public $mtime;
|
||||
|
||||
/**
|
||||
* @var string Partial content.
|
||||
*/
|
||||
public $content;
|
||||
|
||||
/**
|
||||
* @var int The maximum allowed path nesting level. The default value is 2,
|
||||
* meaning that files can only exist in the root directory, or in a
|
||||
* subdirectory. Set to null if any level is allowed.
|
||||
*/
|
||||
protected $maxNesting = 2;
|
||||
|
||||
/**
|
||||
* @var array Allowable file extensions.
|
||||
*/
|
||||
protected $allowedExtensions = ['htm'];
|
||||
|
||||
/**
|
||||
* @var string Default file extension.
|
||||
*/
|
||||
protected $defaultExtension = 'htm';
|
||||
|
||||
/**
|
||||
* Creates an instance of the object and associates it with a CMS component.
|
||||
* @param \Cms\Classes\ComponentBase $component Specifies the component the object belongs to.
|
||||
*/
|
||||
public function __construct(ComponentBase $component)
|
||||
{
|
||||
$this->component = $component;
|
||||
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the object from a file.
|
||||
* This method is used in the CMS back-end. It doesn't use any caching.
|
||||
* @param \Cms\Classes\ComponentBase $component Specifies the component the object belongs to.
|
||||
* @param string $fileName Specifies the file name, with the extension.
|
||||
* The file name can contain only alphanumeric symbols, dashes and dots.
|
||||
* @return mixed Returns a CMS object instance or null if the object wasn't found.
|
||||
*/
|
||||
public static function load($component, $fileName)
|
||||
{
|
||||
return (new static($component))->find($fileName);
|
||||
}
|
||||
|
||||
/**
|
||||
* There is not much point caching a component partial, so this behavior
|
||||
* reverts to a regular load call.
|
||||
* @param \Cms\Classes\ComponentBase $component
|
||||
* @param string $fileName
|
||||
* @return mixed
|
||||
*/
|
||||
public static function loadCached($component, $fileName)
|
||||
{
|
||||
return static::load($component, $fileName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a partial override exists in the supplied theme and returns it.
|
||||
* Since the beginning of time, Winter inconsistently checked for overrides
|
||||
* using the component alias exactly, resulting in a folder with uppercase
|
||||
* characters, subsequently this method checks for both variants.
|
||||
*
|
||||
* @param \Cms\Classes\Theme $theme
|
||||
* @param \Cms\Classes\ComponentBase $component
|
||||
* @param string $fileName
|
||||
* @return mixed
|
||||
*/
|
||||
public static function loadOverrideCached($theme, $component, $fileName)
|
||||
{
|
||||
$partial = Partial::loadCached($theme, strtolower($component->alias) . '/' . $fileName);
|
||||
|
||||
if ($partial === null) {
|
||||
$partial = Partial::loadCached($theme, $component->alias . '/' . $fileName);
|
||||
}
|
||||
|
||||
return $partial;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a single template by its file name.
|
||||
*
|
||||
* @param string $fileName
|
||||
* @return mixed|static
|
||||
*/
|
||||
public function find($fileName)
|
||||
{
|
||||
$fileName = $this->validateFileName($fileName);
|
||||
|
||||
$filePath = $this->getFilePath($fileName);
|
||||
|
||||
if (!File::isFile($filePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (($content = @File::get($filePath)) === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->fileName = $fileName;
|
||||
$this->mtime = File::lastModified($filePath);
|
||||
$this->content = $content;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the specific component contains a matching partial.
|
||||
* @param \Cms\Classes\ComponentBase $component Specifies a component the file belongs to.
|
||||
* @param string $fileName Specifies the file name to check.
|
||||
* @return bool
|
||||
*/
|
||||
public static function check(ComponentBase $component, $fileName)
|
||||
{
|
||||
$partial = new static($component);
|
||||
$filePath = $partial->getFilePath($fileName);
|
||||
if (!strlen(File::extension($filePath))) {
|
||||
$filePath .= '.'.$partial->getDefaultExtension();
|
||||
}
|
||||
|
||||
return File::isFile($filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks the supplied file name for validity.
|
||||
* @param string $fileName
|
||||
* @return string
|
||||
*/
|
||||
protected function validateFileName($fileName)
|
||||
{
|
||||
if (!FileHelper::validatePath($fileName, $this->maxNesting)) {
|
||||
throw new ApplicationException(Lang::get('cms::lang.cms_object.invalid_file', [
|
||||
'name' => $fileName
|
||||
]));
|
||||
}
|
||||
|
||||
if (!strlen(File::extension($fileName))) {
|
||||
$fileName .= '.'.$this->defaultExtension;
|
||||
}
|
||||
|
||||
return $fileName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the file content.
|
||||
* @return string
|
||||
*/
|
||||
public function getContent()
|
||||
{
|
||||
return $this->content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Twig content string.
|
||||
* @return string
|
||||
*/
|
||||
public function getTwigContent()
|
||||
{
|
||||
return $this->content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the key used by the Twig cache.
|
||||
* @return string
|
||||
*/
|
||||
public function getTwigCacheKey()
|
||||
{
|
||||
return $this->getFilePath();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the file name.
|
||||
* @return string
|
||||
*/
|
||||
public function getFileName()
|
||||
{
|
||||
return $this->fileName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the default extension used by this template.
|
||||
* @return string
|
||||
*/
|
||||
public function getDefaultExtension()
|
||||
{
|
||||
return $this->defaultExtension;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the file name without the extension.
|
||||
* @return string
|
||||
*/
|
||||
public function getBaseFileName()
|
||||
{
|
||||
$pos = strrpos($this->fileName, '.');
|
||||
if ($pos === false) {
|
||||
return $this->fileName;
|
||||
}
|
||||
|
||||
return substr($this->fileName, 0, $pos);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the absolute file path.
|
||||
* @param string $fileName Specifies the file name to return the path to.
|
||||
* @return string
|
||||
*/
|
||||
public function getFilePath($fileName = null)
|
||||
{
|
||||
if ($fileName === null) {
|
||||
$fileName = $this->fileName;
|
||||
}
|
||||
|
||||
$component = $this->component;
|
||||
$path = $component->getPath().'/'.$fileName;
|
||||
|
||||
/*
|
||||
* Check the shared "/partials" directory for the partial
|
||||
*/
|
||||
if (!File::isFile($path)) {
|
||||
$sharedDir = dirname($component->getPath()).'/partials';
|
||||
$sharedPath = $sharedDir.'/'.$fileName;
|
||||
if (File::isFile($sharedPath)) {
|
||||
return $sharedPath;
|
||||
}
|
||||
}
|
||||
|
||||
return $path;
|
||||
}
|
||||
}
|
||||
73
modules/cms/classes/Content.php
Normal file
73
modules/cms/classes/Content.php
Normal file
@@ -0,0 +1,73 @@
|
||||
<?php namespace Cms\Classes;
|
||||
|
||||
use File;
|
||||
use Markdown;
|
||||
|
||||
/**
|
||||
* The CMS content file class.
|
||||
*
|
||||
* @package winter\wn-cms-module
|
||||
* @author Alexey Bobkov, Samuel Georges
|
||||
*/
|
||||
class Content extends CmsCompoundObject
|
||||
{
|
||||
/**
|
||||
* @var string The container name associated with the model, eg: pages.
|
||||
*/
|
||||
protected $dirName = 'content';
|
||||
|
||||
/**
|
||||
* @var array Allowable file extensions.
|
||||
*/
|
||||
protected $allowedExtensions = ['htm', 'txt', 'md'];
|
||||
|
||||
/**
|
||||
* @var array List of attribute names which are not considered "settings".
|
||||
*/
|
||||
protected $purgeable = ['parsedMarkup'];
|
||||
|
||||
/**
|
||||
* Initializes the object properties from the cached data. The extra data
|
||||
* set here becomes available as attributes set on the model after fetch.
|
||||
* @param array $item The cached data array.
|
||||
*/
|
||||
public static function initCacheItem(&$item)
|
||||
{
|
||||
$item['parsedMarkup'] = (new static($item))->parseMarkup();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a default value for parsedMarkup attribute.
|
||||
* @return string
|
||||
*/
|
||||
public function getParsedMarkupAttribute()
|
||||
{
|
||||
if (array_key_exists('parsedMarkup', $this->attributes)) {
|
||||
return $this->attributes['parsedMarkup'];
|
||||
}
|
||||
|
||||
return $this->attributes['parsedMarkup'] = $this->parseMarkup();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the content markup according to the file type.
|
||||
* @return string
|
||||
*/
|
||||
public function parseMarkup()
|
||||
{
|
||||
$extension = strtolower(File::extension($this->fileName));
|
||||
|
||||
switch ($extension) {
|
||||
case 'txt':
|
||||
$result = htmlspecialchars($this->markup);
|
||||
break;
|
||||
case 'md':
|
||||
$result = Markdown::parse($this->markup);
|
||||
break;
|
||||
default:
|
||||
$result = $this->markup;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
1650
modules/cms/classes/Controller.php
Normal file
1650
modules/cms/classes/Controller.php
Normal file
File diff suppressed because it is too large
Load Diff
51
modules/cms/classes/Layout.php
Normal file
51
modules/cms/classes/Layout.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php namespace Cms\Classes;
|
||||
|
||||
/**
|
||||
* The CMS layout class.
|
||||
*
|
||||
* @package winter\wn-cms-module
|
||||
* @author Alexey Bobkov, Samuel Georges
|
||||
*/
|
||||
class Layout extends CmsCompoundObject
|
||||
{
|
||||
/**
|
||||
* Fallback layout name.
|
||||
*/
|
||||
const FALLBACK_FILE_NAME = 'fallback';
|
||||
|
||||
/**
|
||||
* @var string The container name associated with the model, eg: pages.
|
||||
*/
|
||||
protected $dirName = 'layouts';
|
||||
|
||||
/**
|
||||
* Initializes the fallback layout.
|
||||
* @param \Cms\Classes\Theme $theme Specifies a theme the file belongs to.
|
||||
* @return \Cms\Classes\Layout
|
||||
*/
|
||||
public static function initFallback($theme)
|
||||
{
|
||||
$obj = self::inTheme($theme);
|
||||
$obj->markup = '{% page %}';
|
||||
$obj->fileName = self::FALLBACK_FILE_NAME;
|
||||
return $obj;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the layout is a fallback layout
|
||||
* @return boolean
|
||||
*/
|
||||
public function isFallBack()
|
||||
{
|
||||
return $this->fileName === self::FALLBACK_FILE_NAME;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns name of a PHP class to us a parent for the PHP class created for the object's PHP section.
|
||||
* @return mixed Returns the class name or null.
|
||||
*/
|
||||
public function getCodeClassParent()
|
||||
{
|
||||
return LayoutCode::class;
|
||||
}
|
||||
}
|
||||
18
modules/cms/classes/LayoutCode.php
Normal file
18
modules/cms/classes/LayoutCode.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php namespace Cms\Classes;
|
||||
|
||||
/**
|
||||
* Parent class for PHP classes created for layout PHP sections.
|
||||
*
|
||||
* @package winter\wn-cms-module
|
||||
* @author Alexey Bobkov, Samuel Georges
|
||||
*/
|
||||
class LayoutCode extends CodeBase
|
||||
{
|
||||
/**
|
||||
* This event is triggered after the layout components are executed,
|
||||
* but before the page's onStart event.
|
||||
*/
|
||||
public function onBeforePageStart()
|
||||
{
|
||||
}
|
||||
}
|
||||
23
modules/cms/classes/MediaLibrary.php
Normal file
23
modules/cms/classes/MediaLibrary.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php namespace Cms\Classes;
|
||||
|
||||
use System\Classes\MediaLibrary as SystemMediaLibrary;
|
||||
|
||||
/**
|
||||
* Provides abstraction level for the Media Library operations.
|
||||
* Implements the library caching features and security checks.
|
||||
*
|
||||
* @package winter\wn-cms-module
|
||||
* @author Alexey Bobkov, Samuel Georges
|
||||
* @deprecated Use System\Classes\MediaLibrary. Remove if year >= 2020.
|
||||
*/
|
||||
class MediaLibrary extends SystemMediaLibrary
|
||||
{
|
||||
/**
|
||||
* Initialize this singleton.
|
||||
*/
|
||||
protected function init()
|
||||
{
|
||||
traceLog('Class ' . __CLASS__ . ' has been deprecated, use ' . SystemMediaLibrary::class . ' instead.');
|
||||
parent::init();
|
||||
}
|
||||
}
|
||||
19
modules/cms/classes/MediaLibraryItem.php
Normal file
19
modules/cms/classes/MediaLibraryItem.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php namespace Cms\Classes;
|
||||
|
||||
use System\Classes\MediaLibraryItem as SystemMediaLibraryItem;
|
||||
|
||||
/**
|
||||
* Represents a file or folder in the Media Library.
|
||||
*
|
||||
* @package winter\wn-cms-module
|
||||
* @author Alexey Bobkov, Samuel Georges
|
||||
* @deprecated Use System\Classes\MediaLibraryItem. Remove if year >= 2020.
|
||||
*/
|
||||
class MediaLibraryItem extends SystemMediaLibraryItem
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
traceLog('Class Cms\Classes\MediaLibraryItem has been deprecated, use ' . SystemMediaLibraryItem::class . ' instead.');
|
||||
parent::__construct(...func_get_args());
|
||||
}
|
||||
}
|
||||
111
modules/cms/classes/MediaViewHelper.php
Normal file
111
modules/cms/classes/MediaViewHelper.php
Normal file
@@ -0,0 +1,111 @@
|
||||
<?php namespace Cms\Classes;
|
||||
|
||||
use ApplicationException;
|
||||
|
||||
/**
|
||||
* Helper class for processing video and audio tags inserted by the Media Manager.
|
||||
*
|
||||
* @package winter\wn-cms-module
|
||||
* @author Alexey Bobkov, Samuel Georges
|
||||
*/
|
||||
class MediaViewHelper
|
||||
{
|
||||
use \Winter\Storm\Support\Traits\Singleton;
|
||||
|
||||
protected $playerPartialFlags = [];
|
||||
|
||||
/**
|
||||
* Replaces audio and video tags inserted by the Media Manager with players markup.
|
||||
* @param string $html Specifies the HTML string to process.
|
||||
* @return string Returns the processed HTML string.
|
||||
*/
|
||||
public function processHtml($html)
|
||||
{
|
||||
if (!is_string($html)) {
|
||||
return $html;
|
||||
}
|
||||
|
||||
$mediaTags = $this->extractMediaTags($html);
|
||||
foreach ($mediaTags as $tagInfo) {
|
||||
$pattern = preg_quote($tagInfo['declaration']);
|
||||
$generatedMarkup = $this->generateMediaTagMarkup($tagInfo['type'], $tagInfo['src']);
|
||||
$html = mb_ereg_replace($pattern, $generatedMarkup, $html);
|
||||
}
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
protected function extractMediaTags($html)
|
||||
{
|
||||
$result = [];
|
||||
$matches = [];
|
||||
|
||||
$tagDefinitions = [
|
||||
'audio' => '/data\-audio\s*=\s*"([^"]+)"/',
|
||||
'video' => '/data\-video\s*=\s*"([^"]+)"/'
|
||||
];
|
||||
|
||||
if (preg_match_all('/\<figure\s+[^\>]+\>[^\<]*\<\/figure\>/i', $html, $matches)) {
|
||||
foreach ($matches[0] as $mediaDeclaration) {
|
||||
foreach ($tagDefinitions as $type => $pattern) {
|
||||
$nameMatch = [];
|
||||
if (preg_match($pattern, $mediaDeclaration, $nameMatch)) {
|
||||
$result[] = [
|
||||
'declaration' => $mediaDeclaration,
|
||||
'type' => $type,
|
||||
'src' => $nameMatch[1]
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
protected function generateMediaTagMarkup($type, $src)
|
||||
{
|
||||
$partialName = $type == 'audio' ? 'oc-audio-player' : 'oc-video-player';
|
||||
|
||||
if ($this->playerPartialExists($partialName)) {
|
||||
return Controller::getController()->renderPartial($partialName, ['src' => $src]);
|
||||
}
|
||||
|
||||
$partialName = $type == 'audio' ? 'wn-audio-player' : 'wn-video-player';
|
||||
|
||||
if ($this->playerPartialExists($partialName)) {
|
||||
return Controller::getController()->renderPartial($partialName, ['src' => $src]);
|
||||
}
|
||||
|
||||
return $this->getDefaultPlayerMarkup($type, $src);
|
||||
}
|
||||
|
||||
protected function playerPartialExists($name)
|
||||
{
|
||||
if (array_key_exists($name, $this->playerPartialFlags)) {
|
||||
return $this->playerPartialFlags[$name];
|
||||
}
|
||||
|
||||
$controller = Controller::getController();
|
||||
if (!$controller) {
|
||||
throw new ApplicationException('Media tags can only be processed for front-end requests.');
|
||||
}
|
||||
|
||||
$partial = Partial::loadCached($controller->getTheme(), $name);
|
||||
|
||||
return $this->playerPartialFlags[$name] = !!$partial;
|
||||
}
|
||||
|
||||
protected function getDefaultPlayerMarkup($type, $src)
|
||||
{
|
||||
switch ($type) {
|
||||
case 'video':
|
||||
return '<video src="'.e($src).'" controls preload="metadata"></video>';
|
||||
break;
|
||||
|
||||
case 'audio':
|
||||
return '<audio src="'.e($src).'" controls preload="metadata"></audio>';
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
85
modules/cms/classes/Meta.php
Normal file
85
modules/cms/classes/Meta.php
Normal file
@@ -0,0 +1,85 @@
|
||||
<?php namespace Cms\Classes;
|
||||
|
||||
use Yaml;
|
||||
|
||||
/**
|
||||
* The CMS meta file class, used for interacting with YAML files within the Halcyon datasources
|
||||
*
|
||||
* @package winter\wn-cms-module
|
||||
* @author Luke Towers
|
||||
*/
|
||||
class Meta extends CmsObject
|
||||
{
|
||||
/**
|
||||
* @var string The container name associated with the model, eg: pages.
|
||||
*/
|
||||
protected $dirName = 'meta';
|
||||
|
||||
/**
|
||||
* @var array Cache store used by parseContent method.
|
||||
*/
|
||||
protected $contentDataCache;
|
||||
|
||||
/**
|
||||
* @var array Allowable file extensions.
|
||||
*/
|
||||
protected $allowedExtensions = ['yaml'];
|
||||
|
||||
/**
|
||||
* @var string Default file extension.
|
||||
*/
|
||||
protected $defaultExtension = 'yaml';
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function __construct(array $attributes = [])
|
||||
{
|
||||
parent::__construct($attributes);
|
||||
|
||||
// Bind data processing to model events
|
||||
$this->bindEvent('model.beforeSave', function () {
|
||||
$this->content = $this->renderContent();
|
||||
});
|
||||
$this->bindEvent('model.afterFetch', function () {
|
||||
$this->attributes = array_merge($this->attributes, $this->parseContent());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes the content attribute to an array of menu data.
|
||||
* @return array|null
|
||||
*/
|
||||
protected function parseContent()
|
||||
{
|
||||
if ($this->contentDataCache !== null) {
|
||||
return $this->contentDataCache;
|
||||
}
|
||||
|
||||
$parsedData = Yaml::parse($this->content);
|
||||
|
||||
if (!is_array($parsedData)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->contentDataCache = $parsedData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the meta data as a content string in YAML format.
|
||||
* @return string
|
||||
*/
|
||||
protected function renderContent()
|
||||
{
|
||||
return Yaml::render($this->settings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compile the content for this CMS object, used by the theme logger.
|
||||
* @return string
|
||||
*/
|
||||
public function toCompiled()
|
||||
{
|
||||
return $this->renderContent();
|
||||
}
|
||||
}
|
||||
12
modules/cms/classes/ObjectMemoryCache.php
Normal file
12
modules/cms/classes/ObjectMemoryCache.php
Normal file
@@ -0,0 +1,12 @@
|
||||
<?php namespace Cms\Classes;
|
||||
|
||||
/**
|
||||
* Provides a simple request-level cache for CMS objects.
|
||||
*
|
||||
* @package winter\wn-cms-module
|
||||
* @author Alexey Bobkov, Samuel Georges
|
||||
*/
|
||||
class ObjectMemoryCache
|
||||
{
|
||||
public static $cache = [];
|
||||
}
|
||||
238
modules/cms/classes/Page.php
Normal file
238
modules/cms/classes/Page.php
Normal file
@@ -0,0 +1,238 @@
|
||||
<?php namespace Cms\Classes;
|
||||
|
||||
use Lang;
|
||||
use BackendAuth;
|
||||
use ApplicationException;
|
||||
use Winter\Storm\Filesystem\Definitions as FileDefinitions;
|
||||
|
||||
/**
|
||||
* The CMS page class.
|
||||
*
|
||||
* @package winter\wn-cms-module
|
||||
* @author Alexey Bobkov, Samuel Georges
|
||||
*/
|
||||
class Page extends CmsCompoundObject
|
||||
{
|
||||
/**
|
||||
* @var string The container name associated with the model, eg: pages.
|
||||
*/
|
||||
protected $dirName = 'pages';
|
||||
|
||||
/**
|
||||
* @var array The attributes that are mass assignable.
|
||||
*/
|
||||
protected $fillable = [
|
||||
'url',
|
||||
'layout',
|
||||
'title',
|
||||
'description',
|
||||
'is_hidden',
|
||||
'meta_title',
|
||||
'meta_description',
|
||||
'markup',
|
||||
'settings',
|
||||
'code'
|
||||
];
|
||||
|
||||
/**
|
||||
* @var array The API bag allows the API handler code to bind arbitrary
|
||||
* data to the page object.
|
||||
*/
|
||||
public $apiBag = [];
|
||||
|
||||
/**
|
||||
* @var array The rules to be applied to the data.
|
||||
*/
|
||||
public $rules = [
|
||||
'title' => 'required',
|
||||
'url' => ['required', 'regex:/^\/[a-z0-9\/\:_\-\*\[\]\+\?\|\.\^\\\$]*$/i']
|
||||
];
|
||||
|
||||
/**
|
||||
* Creates an instance of the object and associates it with a CMS theme.
|
||||
* @param array $attributes
|
||||
*/
|
||||
public function __construct(array $attributes = [])
|
||||
{
|
||||
parent::__construct($attributes);
|
||||
|
||||
$this->customMessages = [
|
||||
'url.regex' => 'cms::lang.page.invalid_url',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns name of a PHP class to us a parent for the PHP class created for the object's PHP section.
|
||||
* @return mixed Returns the class name or null.
|
||||
*/
|
||||
public function getCodeClassParent()
|
||||
{
|
||||
return PageCode::class;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of layouts available in the theme.
|
||||
* This method is used by the form widget.
|
||||
* @return array Returns an array of strings.
|
||||
*/
|
||||
public function getLayoutOptions()
|
||||
{
|
||||
if (!($theme = Theme::getEditTheme())) {
|
||||
throw new ApplicationException(Lang::get('cms::lang.theme.edit.not_found'));
|
||||
}
|
||||
|
||||
$layouts = Layout::listInTheme($theme, true);
|
||||
$result = [];
|
||||
$result[''] = Lang::get('cms::lang.page.no_layout');
|
||||
|
||||
foreach ($layouts as $layout) {
|
||||
$baseName = $layout->getBaseFileName();
|
||||
|
||||
if (FileDefinitions::isPathIgnored($baseName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$result[$baseName] = strlen($layout->name) ? $layout->name : $baseName;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper that returns a nicer list of pages for use in dropdowns.
|
||||
* @return array
|
||||
*/
|
||||
public static function getNameList()
|
||||
{
|
||||
$result = [];
|
||||
$pages = self::sortBy('baseFileName')->all();
|
||||
foreach ($pages as $page) {
|
||||
$result[$page->baseFileName] = $page->title . ' (' . $page->baseFileName . ')';
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper that makes a URL for a page in the active theme.
|
||||
* @param mixed $page Specifies the Cms Page file name.
|
||||
* @param array $params Route parameters to consider in the URL.
|
||||
* @return string|null
|
||||
*/
|
||||
public static function url($page, array $params = [])
|
||||
{
|
||||
/*
|
||||
* Reuse existing controller or create a new one,
|
||||
* assuming that the method is called not during the front-end
|
||||
* request processing.
|
||||
*/
|
||||
$controller = Controller::getController() ?: new Controller;
|
||||
|
||||
return $controller->pageUrl($page, $params, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler for the pages.menuitem.getTypeInfo event.
|
||||
* Returns a menu item type information. The type information is returned as array
|
||||
* with the following elements:
|
||||
* - references - a list of the item type reference options. The options are returned in the
|
||||
* ["key"] => "title" format for options that don't have sub-options, and in the format
|
||||
* ["key"] => ["title"=>"Option title", "items"=>[...]] for options that have sub-options. Optional,
|
||||
* required only if the menu item type requires references.
|
||||
* - nesting - Boolean value indicating whether the item type supports nested items. Optional,
|
||||
* false if omitted.
|
||||
* - dynamicItems - Boolean value indicating whether the item type could generate new menu items.
|
||||
* Optional, false if omitted.
|
||||
* - cmsPages - a list of CMS pages (objects of the Cms\Classes\Page class), if the item type requires
|
||||
* a CMS page reference to resolve the item URL.
|
||||
* @param string $type Specifies the menu item type
|
||||
* @return array Returns an array
|
||||
*/
|
||||
public static function getMenuTypeInfo(string $type)
|
||||
{
|
||||
$result = [];
|
||||
|
||||
if ($type === 'cms-page') {
|
||||
$theme = Theme::getActiveTheme();
|
||||
$pages = self::listInTheme($theme, true);
|
||||
$references = [];
|
||||
|
||||
foreach ($pages as $page) {
|
||||
$references[$page->getBaseFileName()] = $page->title . ' [' . $page->getBaseFileName() . ']';
|
||||
}
|
||||
|
||||
$result = [
|
||||
'references' => $references,
|
||||
'nesting' => false,
|
||||
'dynamicItems' => false
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler for the pages.menuitem.resolveItem event.
|
||||
* Returns information about a menu item. The result is an array
|
||||
* with the following keys:
|
||||
* - url - the menu item URL. Not required for menu item types that return all available records.
|
||||
* The URL should be returned relative to the website root and include the subdirectory, if any.
|
||||
* Use the Url::to() helper to generate the URLs.
|
||||
* - isActive - determines whether the menu item is active. Not required for menu item types that
|
||||
* return all available records.
|
||||
* - items - an array of arrays with the same keys (url, isActive, items) + the title key.
|
||||
* The items array should be added only if the $item's $nesting property value is TRUE.
|
||||
*
|
||||
* @param \Winter\Sitemap\Classes\DefinitionItem|\Winter\Pages\Classes\MenuItem $item Specifies the menu item.
|
||||
*/
|
||||
public static function resolveMenuItem(object $item, string $url, Theme $theme, bool $routePersistence = false): ?array
|
||||
{
|
||||
$result = null;
|
||||
|
||||
if ($item->type === 'cms-page') {
|
||||
if (!$item->reference) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$page = self::loadCached($theme, $item->reference);
|
||||
|
||||
// Remove hidden CMS pages from menus when backend user is logged out
|
||||
if ($page && $page->is_hidden && !BackendAuth::getUser()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$controller = Controller::getController() ?: new Controller;
|
||||
$pageUrl = $controller->pageUrl($item->reference, [], $routePersistence);
|
||||
|
||||
$result = [];
|
||||
$result['url'] = $pageUrl;
|
||||
$result['isActive'] = rtrim($pageUrl, '/') === rtrim($url, '/');
|
||||
$result['mtime'] = $page ? $page->mtime : null;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler for the backend.richeditor.getTypeInfo event.
|
||||
* Returns a menu item type information. The type information is returned as array
|
||||
* @param string $type Specifies the page link type
|
||||
* @return array
|
||||
*/
|
||||
public static function getRichEditorTypeInfo(string $type)
|
||||
{
|
||||
$result = [];
|
||||
|
||||
if ($type === 'cms-page') {
|
||||
$theme = Theme::getActiveTheme();
|
||||
$pages = self::listInTheme($theme, true);
|
||||
|
||||
foreach ($pages as $page) {
|
||||
$url = self::url($page->getBaseFileName());
|
||||
$result[$url] = $page->title;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
11
modules/cms/classes/PageCode.php
Normal file
11
modules/cms/classes/PageCode.php
Normal file
@@ -0,0 +1,11 @@
|
||||
<?php namespace Cms\Classes;
|
||||
|
||||
/**
|
||||
* Parent class for PHP classes created for page PHP sections.
|
||||
*
|
||||
* @package winter\wn-cms-module
|
||||
* @author Alexey Bobkov, Samuel Georges
|
||||
*/
|
||||
class PageCode extends CodeBase
|
||||
{
|
||||
}
|
||||
24
modules/cms/classes/Partial.php
Normal file
24
modules/cms/classes/Partial.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php namespace Cms\Classes;
|
||||
|
||||
/**
|
||||
* The CMS partial class.
|
||||
*
|
||||
* @package winter\wn-cms-module
|
||||
* @author Alexey Bobkov, Samuel Georges
|
||||
*/
|
||||
class Partial extends CmsCompoundObject
|
||||
{
|
||||
/**
|
||||
* @var string The container name associated with the model, eg: pages.
|
||||
*/
|
||||
protected $dirName = 'partials';
|
||||
|
||||
/**
|
||||
* Returns name of a PHP class to us a parent for the PHP class created for the object's PHP section.
|
||||
* @return string Returns the class name.
|
||||
*/
|
||||
public function getCodeClassParent()
|
||||
{
|
||||
return PartialCode::class;
|
||||
}
|
||||
}
|
||||
11
modules/cms/classes/PartialCode.php
Normal file
11
modules/cms/classes/PartialCode.php
Normal file
@@ -0,0 +1,11 @@
|
||||
<?php namespace Cms\Classes;
|
||||
|
||||
/**
|
||||
* Parent class for PHP classes created for partial PHP sections.
|
||||
*
|
||||
* @package winter\wn-cms-module
|
||||
* @author Alexey Bobkov, Samuel Georges
|
||||
*/
|
||||
class PartialCode extends CodeBase
|
||||
{
|
||||
}
|
||||
93
modules/cms/classes/PartialStack.php
Normal file
93
modules/cms/classes/PartialStack.php
Normal file
@@ -0,0 +1,93 @@
|
||||
<?php namespace Cms\Classes;
|
||||
|
||||
/**
|
||||
* Manager class for stacking nested partials and keeping track
|
||||
* of their components. Partial "objects" store the components
|
||||
* used by that partial for deferred retrieval.
|
||||
*
|
||||
* @package winter\wn-cms-module
|
||||
* @author Alexey Bobkov, Samuel Georges
|
||||
*/
|
||||
class PartialStack
|
||||
{
|
||||
/**
|
||||
* @var array The current partial "object" being rendered.
|
||||
*/
|
||||
public $activePartial;
|
||||
|
||||
/**
|
||||
* @var array Collection of previously rendered partial "objects".
|
||||
*/
|
||||
protected $partialStack = [];
|
||||
|
||||
/**
|
||||
* Partial entry point, appends a new partial to the stack.
|
||||
*/
|
||||
public function stackPartial()
|
||||
{
|
||||
if ($this->activePartial !== null) {
|
||||
array_unshift($this->partialStack, $this->activePartial);
|
||||
}
|
||||
|
||||
$this->activePartial = [
|
||||
'components' => []
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Partial exit point, removes the active partial from the stack.
|
||||
*/
|
||||
public function unstackPartial()
|
||||
{
|
||||
$this->activePartial = array_shift($this->partialStack);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a component to the active partial stack.
|
||||
*/
|
||||
public function addComponent($alias, $componentObj)
|
||||
{
|
||||
array_push($this->activePartial['components'], [
|
||||
'name' => $alias,
|
||||
'obj' => $componentObj
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a component by its alias from the partial stack.
|
||||
*/
|
||||
public function getComponent($name)
|
||||
{
|
||||
if (!$this->activePartial) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$component = $this->findComponentFromStack($name, $this->activePartial);
|
||||
if ($component !== null) {
|
||||
return $component;
|
||||
}
|
||||
|
||||
foreach ($this->partialStack as $stack) {
|
||||
$component = $this->findComponentFromStack($name, $stack);
|
||||
if ($component !== null) {
|
||||
return $component;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Locates a component by its alias from the supplied stack.
|
||||
*/
|
||||
protected function findComponentFromStack($name, $stack)
|
||||
{
|
||||
foreach ($stack['components'] as $componentInfo) {
|
||||
if ($componentInfo['name'] == $name) {
|
||||
return $componentInfo['obj'];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
358
modules/cms/classes/Router.php
Normal file
358
modules/cms/classes/Router.php
Normal file
@@ -0,0 +1,358 @@
|
||||
<?php namespace Cms\Classes;
|
||||
|
||||
use Lang;
|
||||
use File;
|
||||
use Cache;
|
||||
use Config;
|
||||
use Event;
|
||||
use Winter\Storm\Router\Router as StormRouter;
|
||||
use Winter\Storm\Router\Helper as RouterHelper;
|
||||
|
||||
/**
|
||||
* The router parses page URL patterns and finds pages by URLs.
|
||||
*
|
||||
* The page URL format is explained below.
|
||||
* <pre>/blog/post/:post_id</pre>
|
||||
* Name of parameters should be compatible with PHP variable names. To make a parameter optional
|
||||
* add the question mark after its name:
|
||||
* <pre>/blog/post/:post_id?</pre>
|
||||
* By default parameters in the middle of the URL are required, for example:
|
||||
* <pre>/blog/:post_id?/comments - although the :post_id parameter is marked as optional,
|
||||
* it will be processed as required.</pre>
|
||||
* Optional parameters can have default values which are used as fallback values in case if the real
|
||||
* parameter value is not presented in the URL. Default values cannot contain the pipe symbols and question marks.
|
||||
* Specify the default value after the question mark:
|
||||
* <pre>/blog/category/:category_id?10 - The category_id parameter would be 10 for this URL: /blog/category</pre>
|
||||
* You can also add regular expression validation to parameters. To add a validation expression
|
||||
* add the pipe symbol after the parameter name (or the question mark) and specify the expression.
|
||||
* The forward slash symbol is not allowed in the expressions. Examples:
|
||||
* <pre>/blog/:post_id|^[0-9]+$/comments - this will match /blog/post/10/comments
|
||||
* /blog/:post_id|^[0-9]+$ - this will match /blog/post/3
|
||||
* /blog/:post_name?|^[a-z0-9\-]+$ - this will match /blog/my-blog-post</pre>
|
||||
*
|
||||
* @package winter\wn-cms-module
|
||||
* @author Alexey Bobkov, Samuel Georges
|
||||
*/
|
||||
class Router
|
||||
{
|
||||
/**
|
||||
* @var \Cms\Classes\Theme A reference to the CMS theme containing the object.
|
||||
*/
|
||||
protected $theme;
|
||||
|
||||
/**
|
||||
* @var string The last URL to be looked up using findByUrl().
|
||||
*/
|
||||
protected $url;
|
||||
|
||||
/**
|
||||
* @var array A list of parameters names and values extracted from the URL pattern and URL string.
|
||||
*/
|
||||
protected $parameters = [];
|
||||
|
||||
/**
|
||||
* @var array Contains the URL map - the list of page file names and corresponding URL patterns.
|
||||
*/
|
||||
protected $urlMap = [];
|
||||
|
||||
/**
|
||||
* Winter\Storm\Router\Router Router object with routes preloaded.
|
||||
*/
|
||||
protected $routerObj;
|
||||
|
||||
/**
|
||||
* Creates the router instance.
|
||||
* @param \Cms\Classes\Theme $theme Specifies the theme being processed.
|
||||
*/
|
||||
public function __construct(Theme $theme)
|
||||
{
|
||||
$this->theme = $theme;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a page by its URL. Returns the page object and sets the $parameters property.
|
||||
* @param string $url The requested URL string.
|
||||
* @return \Cms\Classes\Page Returns \Cms\Classes\Page object or null if the page cannot be found.
|
||||
*/
|
||||
public function findByUrl($url)
|
||||
{
|
||||
$this->url = $url;
|
||||
$url = RouterHelper::normalizeUrl($url);
|
||||
|
||||
/**
|
||||
* @event cms.router.beforeRoute
|
||||
* Fires before the CMS Router handles a route
|
||||
*
|
||||
* Example usage:
|
||||
*
|
||||
* Event::listen('cms.router.beforeRoute', function ((string) $url, (\Cms\Classes\Router) $thisRouterInstance) {
|
||||
* return \Cms\Classes\Page::loadCached('trick-theme-code', 'page-file-name');
|
||||
* });
|
||||
*
|
||||
*/
|
||||
$apiResult = Event::fire('cms.router.beforeRoute', [$url, $this], true);
|
||||
if ($apiResult !== null) {
|
||||
return $apiResult;
|
||||
}
|
||||
|
||||
for ($pass = 1; $pass <= 2; $pass++) {
|
||||
$fileName = null;
|
||||
$urlList = [];
|
||||
|
||||
$cacheable = Config::get('cms.enableRoutesCache');
|
||||
if ($cacheable) {
|
||||
$fileName = $this->getCachedUrlFileName($url, $urlList);
|
||||
if (is_array($fileName)) {
|
||||
list($fileName, $this->parameters) = $fileName;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Find the page by URL and cache the route
|
||||
*/
|
||||
if (!$fileName) {
|
||||
$router = $this->getRouterObject();
|
||||
if ($router->match($url)) {
|
||||
$this->parameters = $router->getParameters();
|
||||
|
||||
$fileName = $router->matchedRoute();
|
||||
|
||||
if ($cacheable) {
|
||||
if (!$urlList || !is_array($urlList)) {
|
||||
$urlList = [];
|
||||
}
|
||||
|
||||
$urlList[$url] = !empty($this->parameters)
|
||||
? [$fileName, $this->parameters]
|
||||
: $fileName;
|
||||
|
||||
$key = $this->getUrlListCacheKey();
|
||||
$expiresAt = now()->addMinutes(Config::get('cms.urlCacheTtl', 1));
|
||||
Cache::put(
|
||||
$key,
|
||||
base64_encode(serialize($urlList)),
|
||||
$expiresAt
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Return the page
|
||||
*/
|
||||
if ($fileName) {
|
||||
if (($page = Page::loadCached($this->theme, $fileName)) === null) {
|
||||
/*
|
||||
* If the page was not found on the disk, clear the URL cache
|
||||
* and repeat the routing process.
|
||||
*/
|
||||
if ($pass == 1) {
|
||||
$this->clearCache();
|
||||
continue;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return $page;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a URL by it's page. Returns the URL route for linking to the page and uses the supplied
|
||||
* parameters in it's address.
|
||||
* @param string $fileName Page file name.
|
||||
* @param array $parameters Route parameters to consider in the URL.
|
||||
* @return string A built URL matching the page route.
|
||||
*/
|
||||
public function findByFile($fileName, $parameters = [])
|
||||
{
|
||||
if (!strlen(File::extension($fileName))) {
|
||||
$fileName .= '.htm';
|
||||
}
|
||||
|
||||
$router = $this->getRouterObject();
|
||||
return $router->url($fileName, $parameters);
|
||||
}
|
||||
|
||||
/**
|
||||
* Autoloads the URL map only allowing a single execution.
|
||||
* @return array Returns the URL map.
|
||||
*/
|
||||
protected function getRouterObject()
|
||||
{
|
||||
if ($this->routerObj !== null) {
|
||||
return $this->routerObj;
|
||||
}
|
||||
|
||||
/*
|
||||
* Load up each route rule
|
||||
*/
|
||||
$router = new StormRouter();
|
||||
foreach ($this->getUrlMap() as $pageInfo) {
|
||||
$router->route($pageInfo['file'], $pageInfo['pattern']);
|
||||
}
|
||||
|
||||
/*
|
||||
* Sort all the rules
|
||||
*/
|
||||
$router->sortRules();
|
||||
|
||||
return $this->routerObj = $router;
|
||||
}
|
||||
|
||||
/**
|
||||
* Autoloads the URL map only allowing a single execution.
|
||||
* @return array Returns the URL map.
|
||||
*/
|
||||
protected function getUrlMap()
|
||||
{
|
||||
if (!count($this->urlMap)) {
|
||||
$this->loadUrlMap();
|
||||
}
|
||||
|
||||
return $this->urlMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the URL map - a list of page file names and corresponding URL patterns.
|
||||
* The URL map can is cached. The clearUrlMap() method resets the cache. By default
|
||||
* the map is updated every time when a page is saved in the back-end, or
|
||||
* when the interval defined with the cms.urlCacheTtl expires.
|
||||
* @return boolean Returns true if the URL map was loaded from the cache. Otherwise returns false.
|
||||
*/
|
||||
protected function loadUrlMap()
|
||||
{
|
||||
$key = $this->getCacheKey('page-url-map');
|
||||
|
||||
$cacheable = Config::get('cms.enableRoutesCache');
|
||||
if ($cacheable) {
|
||||
$cached = Cache::get($key, false);
|
||||
}
|
||||
else {
|
||||
$cached = false;
|
||||
}
|
||||
|
||||
if (!$cached || ($unserialized = @unserialize(@base64_decode($cached))) === false) {
|
||||
/*
|
||||
* The item doesn't exist in the cache, create the map
|
||||
*/
|
||||
$pages = $this->theme->listPages();
|
||||
$map = [];
|
||||
foreach ($pages as $page) {
|
||||
if (!$page->url) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$map[] = ['file' => $page->getFileName(), 'pattern' => $page->url];
|
||||
}
|
||||
|
||||
$this->urlMap = $map;
|
||||
if ($cacheable) {
|
||||
$expiresAt = now()->addMinutes(Config::get('cms.urlCacheTtl', 1));
|
||||
Cache::put($key, base64_encode(serialize($map)), $expiresAt);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->urlMap = $unserialized;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the router cache.
|
||||
*/
|
||||
public function clearCache()
|
||||
{
|
||||
Cache::forget($this->getCacheKey('page-url-map'));
|
||||
Cache::forget($this->getCacheKey('cms-url-list'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the current routing parameters.
|
||||
* @param array $parameters
|
||||
* @return array
|
||||
*/
|
||||
public function setParameters(array $parameters)
|
||||
{
|
||||
$this->parameters = $parameters;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current routing parameters.
|
||||
* @return array
|
||||
*/
|
||||
public function getParameters()
|
||||
{
|
||||
return $this->parameters;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the last URL to be looked up.
|
||||
* @return string
|
||||
*/
|
||||
public function getUrl()
|
||||
{
|
||||
return $this->url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a routing parameter.
|
||||
* @param string $name
|
||||
* @param string|null $default
|
||||
* @return string|null
|
||||
*/
|
||||
public function getParameter($name, $default = null)
|
||||
{
|
||||
if (isset($this->parameters[$name]) && ($this->parameters[$name] === '0' || !empty($this->parameters[$name]))) {
|
||||
return $this->parameters[$name];
|
||||
}
|
||||
|
||||
return $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the caching URL key depending on the theme.
|
||||
* @param string $keyName Specifies the base key name.
|
||||
* @return string Returns the theme-specific key name.
|
||||
*/
|
||||
protected function getCacheKey($keyName)
|
||||
{
|
||||
return md5($this->theme->getPath()).$keyName.Lang::getLocale();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the cache key name for the URL list.
|
||||
* @return string
|
||||
*/
|
||||
protected function getUrlListCacheKey()
|
||||
{
|
||||
return $this->getCacheKey('cms-url-list');
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to load a page file name corresponding to a specified URL from the cache.
|
||||
* @param string $url Specifies the requested URL.
|
||||
* @param array &$urlList The URL list loaded from the cache
|
||||
* @return mixed Returns the page file name if the URL exists in the cache. Otherwise returns null.
|
||||
*/
|
||||
protected function getCachedUrlFileName($url, &$urlList)
|
||||
{
|
||||
$key = $this->getUrlListCacheKey();
|
||||
$urlList = Cache::get($key, false);
|
||||
|
||||
if ($urlList
|
||||
&& ($urlList = @unserialize(@base64_decode($urlList)))
|
||||
&& is_array($urlList)
|
||||
&& array_key_exists($url, $urlList)
|
||||
) {
|
||||
return $urlList[$url];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
735
modules/cms/classes/Theme.php
Normal file
735
modules/cms/classes/Theme.php
Normal file
@@ -0,0 +1,735 @@
|
||||
<?php
|
||||
|
||||
namespace Cms\Classes;
|
||||
|
||||
use Cms\Models\ThemeData;
|
||||
use DirectoryIterator;
|
||||
use Exception;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Lang;
|
||||
use System\Models\Parameter;
|
||||
use Winter\Storm\Exception\ApplicationException;
|
||||
use Winter\Storm\Exception\SystemException;
|
||||
use Winter\Storm\Halcyon\Datasource\DatasourceInterface;
|
||||
use Winter\Storm\Halcyon\Datasource\DbDatasource;
|
||||
use Winter\Storm\Halcyon\Datasource\FileDatasource;
|
||||
use Winter\Storm\Support\Facades\Config;
|
||||
use Winter\Storm\Support\Facades\Event;
|
||||
use Winter\Storm\Support\Facades\File;
|
||||
use Winter\Storm\Support\Facades\Url;
|
||||
use Winter\Storm\Support\Facades\Yaml;
|
||||
use Winter\Storm\Support\Str;
|
||||
|
||||
/**
|
||||
* This class represents the CMS theme.
|
||||
* CMS theme is a directory that contains all CMS objects - pages, layouts, partials and asset files..
|
||||
* The theme parameters are specified in the theme.ini file in the theme root directory.
|
||||
*
|
||||
* @package winter\wn-cms-module
|
||||
* @author Alexey Bobkov, Samuel Georges
|
||||
*/
|
||||
class Theme extends CmsObject
|
||||
{
|
||||
/**
|
||||
* @var string Specifies the theme directory name.
|
||||
*/
|
||||
protected $dirName;
|
||||
|
||||
/**
|
||||
* @var mixed Keeps the cached configuration file values.
|
||||
*/
|
||||
protected $configCache;
|
||||
|
||||
/**
|
||||
* @var mixed Active theme cache in memory
|
||||
*/
|
||||
protected static $activeThemeCache = false;
|
||||
|
||||
/**
|
||||
* @var mixed Edit theme cache in memory
|
||||
*/
|
||||
protected static $editThemeCache = false;
|
||||
|
||||
/**
|
||||
* @var array Allowable file extensions.
|
||||
*/
|
||||
protected $allowedExtensions = ['yaml'];
|
||||
|
||||
/**
|
||||
* @var string Default file extension.
|
||||
*/
|
||||
protected $defaultExtension = 'yaml';
|
||||
|
||||
const ACTIVE_KEY = 'cms::theme.active';
|
||||
const EDIT_KEY = 'cms::theme.edit';
|
||||
|
||||
/**
|
||||
* Loads the theme.
|
||||
*/
|
||||
public static function load($dirName, $file = null): ?static
|
||||
{
|
||||
$theme = new static;
|
||||
$theme->setDirName($dirName);
|
||||
$theme->registerHalcyonDatasource();
|
||||
if (App::runningInBackend()) {
|
||||
$theme->registerBackendLocalization();
|
||||
}
|
||||
|
||||
return $theme;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the absolute theme path.
|
||||
*/
|
||||
public function getPath(?string $dirName = null): string
|
||||
{
|
||||
if (!$dirName) {
|
||||
$dirName = $this->getDirName();
|
||||
}
|
||||
|
||||
return themes_path($dirName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the theme directory name.
|
||||
* @throws ApplicationException if the directory name is invalid.
|
||||
*/
|
||||
public function setDirName(string $dirName): void
|
||||
{
|
||||
if (!static::isValidDirName($dirName)) {
|
||||
throw new ApplicationException(Lang::get('cms::lang.theme.dir_name_invalid'));
|
||||
}
|
||||
|
||||
$this->dirName = $dirName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the theme directory name.
|
||||
*/
|
||||
public function getDirName(): string
|
||||
{
|
||||
return $this->dirName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if the given directory name is valid.
|
||||
*/
|
||||
public static function isValidDirName(string $dirName): bool
|
||||
{
|
||||
return (bool) preg_match('/^[a-z0-9\_\-]+$/i', $dirName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper for {{ theme.id }} twig vars
|
||||
* Returns a unique string for this theme.
|
||||
*/
|
||||
public function getId(): string
|
||||
{
|
||||
return snake_case(str_replace('/', '-', $this->getDirName()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if a theme with given directory name exists
|
||||
*/
|
||||
public static function exists(string $dirName): bool
|
||||
{
|
||||
$theme = static::load($dirName);
|
||||
$path = $theme->getPath();
|
||||
|
||||
return File::isDirectory($path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of pages in the theme.
|
||||
* This method is used internally in the routing process and in the back-end UI.
|
||||
*/
|
||||
public function listPages(bool $skipCache = false): \Cms\Classes\CmsObjectCollection
|
||||
{
|
||||
return Page::listInTheme($this, $skipCache);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if this theme is the chosen active theme.
|
||||
*/
|
||||
public function isActiveTheme(): bool
|
||||
{
|
||||
$activeTheme = self::getActiveTheme();
|
||||
|
||||
return $activeTheme && $activeTheme->getDirName() === $this->getDirName();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the active theme code.
|
||||
* By default the active theme is loaded from the cms.activeTheme parameter,
|
||||
* but this behavior can be overridden by the cms.theme.getActiveTheme event listener.
|
||||
* If the theme doesn't exist, returns null.
|
||||
*/
|
||||
public static function getActiveThemeCode(): string
|
||||
{
|
||||
/**
|
||||
* @event cms.theme.getActiveTheme
|
||||
* Overrides the active theme code.
|
||||
*
|
||||
* If a value is returned from this halting event, it will be used as the active
|
||||
* theme code. Example usage:
|
||||
*
|
||||
* Event::listen('cms.theme.getActiveTheme', function () {
|
||||
* return 'mytheme';
|
||||
* });
|
||||
*
|
||||
*/
|
||||
$apiResult = Event::fire('cms.theme.getActiveTheme', [], true);
|
||||
if ($apiResult !== null) {
|
||||
return $apiResult;
|
||||
}
|
||||
|
||||
// Load the active theme from the configuration
|
||||
$activeTheme = $configuredTheme = Config::get('cms.activeTheme');
|
||||
|
||||
// Attempt to load the active theme from the cache before checking the database
|
||||
try {
|
||||
$cached = Cache::get(self::ACTIVE_KEY, null);
|
||||
if (
|
||||
is_array($cached)
|
||||
// Check if the configured theme has changed
|
||||
&& $cached['config'] === $configuredTheme
|
||||
) {
|
||||
return $cached['active'];
|
||||
}
|
||||
} catch (Exception $ex) {
|
||||
// Cache failed
|
||||
}
|
||||
|
||||
// Check the database
|
||||
if (App::hasDatabase()) {
|
||||
try {
|
||||
$dbResult = Parameter::applyKey(self::ACTIVE_KEY)->value('value');
|
||||
} catch (Exception $ex) {
|
||||
$dbResult = null;
|
||||
}
|
||||
|
||||
if ($dbResult !== null && static::exists($dbResult)) {
|
||||
$activeTheme = $dbResult;
|
||||
}
|
||||
}
|
||||
|
||||
if (!strlen($activeTheme)) {
|
||||
throw new SystemException(Lang::get('cms::lang.theme.active.not_set'));
|
||||
}
|
||||
|
||||
// Cache the results
|
||||
try {
|
||||
Cache::forever(self::ACTIVE_KEY, [
|
||||
'config' => $configuredTheme,
|
||||
'active' => $activeTheme,
|
||||
]);
|
||||
} catch (Exception $ex) {
|
||||
// Cache failed
|
||||
}
|
||||
|
||||
return $activeTheme;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the active theme object.
|
||||
* If the theme doesn't exist, returns null.
|
||||
*/
|
||||
public static function getActiveTheme(): self
|
||||
{
|
||||
if (self::$activeThemeCache !== false) {
|
||||
return self::$activeThemeCache;
|
||||
}
|
||||
|
||||
$theme = static::load(static::getActiveThemeCode());
|
||||
|
||||
|
||||
return self::$activeThemeCache = $theme;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the active theme in the database.
|
||||
* The active theme code is stored in the database and overrides the configuration cms.activeTheme parameter.
|
||||
* @throws ApplicationException if the directory name is invalid.
|
||||
*/
|
||||
public static function setActiveTheme(string $code): void
|
||||
{
|
||||
if (!static::isValidDirName($code)) {
|
||||
throw new ApplicationException(Lang::get('cms::lang.theme.dir_name_invalid'));
|
||||
}
|
||||
|
||||
self::resetCache();
|
||||
|
||||
Parameter::set(self::ACTIVE_KEY, $code);
|
||||
|
||||
/**
|
||||
* @event cms.theme.setActiveTheme
|
||||
* Fires when the active theme has been changed.
|
||||
*
|
||||
* If a value is returned from this halting event, it will be used as the active
|
||||
* theme code. Example usage:
|
||||
*
|
||||
* Event::listen('cms.theme.setActiveTheme', function ($code) {
|
||||
* \Log::info("Theme has been changed to $code");
|
||||
* });
|
||||
*
|
||||
*/
|
||||
Event::fire('cms.theme.setActiveTheme', compact('code'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the edit theme code.
|
||||
* By default the edit theme is loaded from the cms.editTheme parameter,
|
||||
* but this behavior can be overridden by the cms.theme.getEditTheme event listeners.
|
||||
* If the edit theme is not defined in the configuration file, the active theme
|
||||
* is returned.
|
||||
*
|
||||
* @throws SystemException if the edit theme cannot be determined
|
||||
*/
|
||||
public static function getEditThemeCode(): string
|
||||
{
|
||||
/**
|
||||
* @event cms.theme.getEditTheme
|
||||
* Overrides the edit theme code.
|
||||
*
|
||||
* If a value is returned from this halting event, it will be used as the edit
|
||||
* theme code. Example usage:
|
||||
*
|
||||
* Event::listen('cms.theme.getEditTheme', function () {
|
||||
* return "the-edit-theme-code";
|
||||
* });
|
||||
*
|
||||
*/
|
||||
$apiResult = Event::fire('cms.theme.getEditTheme', [], true);
|
||||
if ($apiResult !== null) {
|
||||
return $apiResult;
|
||||
}
|
||||
|
||||
$editTheme = Config::get('cms.editTheme');
|
||||
if (!$editTheme) {
|
||||
$editTheme = static::getActiveThemeCode();
|
||||
}
|
||||
|
||||
if (!strlen($editTheme)) {
|
||||
throw new SystemException(Lang::get('cms::lang.theme.edit.not_set'));
|
||||
}
|
||||
|
||||
return $editTheme;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the edit theme.
|
||||
*/
|
||||
public static function getEditTheme(): self
|
||||
{
|
||||
if (self::$editThemeCache !== false) {
|
||||
return self::$editThemeCache;
|
||||
}
|
||||
|
||||
$theme = static::load(static::getEditThemeCode());
|
||||
|
||||
|
||||
return self::$editThemeCache = $theme;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array of all themes.
|
||||
*/
|
||||
public static function all(): array
|
||||
{
|
||||
$it = new DirectoryIterator(themes_path());
|
||||
$it->rewind();
|
||||
|
||||
$result = [];
|
||||
foreach ($it as $fileinfo) {
|
||||
if (!$fileinfo->isDir() || $fileinfo->isDot()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$theme = static::load($fileinfo->getFilename());
|
||||
|
||||
$result[] = $theme;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the theme.yaml file and returns the theme configuration values.
|
||||
*/
|
||||
public function getConfig(): array
|
||||
{
|
||||
if ($this->configCache !== null) {
|
||||
return $this->configCache;
|
||||
}
|
||||
|
||||
// Attempt to load the theme's config file from whatever datasources are available.
|
||||
$sources = [
|
||||
'filesystem' => new FileDatasource(themes_path($this->getDirName()), App::make('files'))
|
||||
];
|
||||
if (static::databaseLayerEnabled()) {
|
||||
$sources['database'] = new DbDatasource($this->getDirName(), 'cms_theme_templates');
|
||||
}
|
||||
$data = (new AutoDatasource($sources))->selectOne('', 'theme', 'yaml');
|
||||
|
||||
if (!$data) {
|
||||
return $this->configCache = [];
|
||||
}
|
||||
|
||||
$config = Yaml::parse($data['content']) ?: [];
|
||||
|
||||
/**
|
||||
* @event cms.theme.extendConfig
|
||||
* Extend basic theme configuration supplied by the theme by returning an array.
|
||||
*
|
||||
* Note if planning on extending form fields, use the `cms.theme.extendFormConfig`
|
||||
* event instead.
|
||||
*
|
||||
* Example usage:
|
||||
*
|
||||
* Event::listen('cms.theme.extendConfig', function ($themeCode, &$config) {
|
||||
* $config['name'] = 'Winter Theme';
|
||||
* $config['description'] = 'Another great theme from Winter CMS';
|
||||
* });
|
||||
*
|
||||
*/
|
||||
Event::fire('cms.theme.extendConfig', [$this->getDirName(), &$config]);
|
||||
|
||||
return $this->configCache = $config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Themes have a dedicated `form` option that provide form fields
|
||||
* for customization, this is an immutable accessor for that and
|
||||
* also an solid anchor point for extension.
|
||||
*/
|
||||
public function getFormConfig(): array
|
||||
{
|
||||
$config = $this->getConfigArray('form');
|
||||
|
||||
/**
|
||||
* @event cms.theme.extendFormConfig
|
||||
* Extend form field configuration supplied by the theme by returning an array.
|
||||
*
|
||||
* Note if you are planning on using `assetVar` to inject CSS variables from a
|
||||
* plugin registration file, make sure the plugin has elevated permissions.
|
||||
*
|
||||
* Example usage:
|
||||
*
|
||||
* Event::listen('cms.theme.extendFormConfig', function ($themeCode, &$config) {
|
||||
* array_set($config, 'tabs.fields.header_color', [
|
||||
* 'label' => 'Header Colour',
|
||||
* 'type' => 'colorpicker',
|
||||
* 'availableColors' => [#103141, #708598, #6cc551],
|
||||
* 'assetVar' => 'header-bg',
|
||||
* 'tab' => 'Global'
|
||||
* ]);
|
||||
* });
|
||||
*
|
||||
*/
|
||||
Event::fire('cms.theme.extendFormConfig', [$this->getDirName(), &$config]);
|
||||
|
||||
return $config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates an asset URL for the provided path within the theme, will use the parent theme
|
||||
* if the current theme does not actually have a directory on the filesystem (i.e. is virtual).
|
||||
*/
|
||||
public function assetUrl(?string $path): string
|
||||
{
|
||||
$expiresAt = now()->addMinutes(Config::get('cms.urlCacheTtl', 10));
|
||||
$key = sprintf('winter.cms.%s.assetUrl.%s.%s', $this->dirName, request()->getSchemeAndHttpHost(), $path);
|
||||
return Cache::remember($key, $expiresAt, function () use ($path) {
|
||||
// Handle symbolized paths
|
||||
if ($path && File::isPathSymbol($path)) {
|
||||
return Url::asset(File::localToPublic(File::symbolizePath($path)));
|
||||
}
|
||||
|
||||
$config = $this->getConfig();
|
||||
$themeDir = $this->getDirName();
|
||||
|
||||
// If the active theme does not have a directory, then just check the parent theme
|
||||
if (!File::isDirectory(themes_path($this->getDirName())) && !empty($config['parent'])) {
|
||||
$themeDir = $config['parent'];
|
||||
}
|
||||
|
||||
// Define a helper for constructing the URL
|
||||
$urlPath = function ($themeDir, $path) {
|
||||
$_url = Config::get('cms.themesPath', '/themes') . '/' . $themeDir;
|
||||
|
||||
if ($path !== null) {
|
||||
$_url .= '/' . $path;
|
||||
}
|
||||
|
||||
return $_url;
|
||||
};
|
||||
|
||||
$url = $urlPath($themeDir, $path);
|
||||
|
||||
// If the file cannot be found in the theme, generate a url for the parent theme
|
||||
if (!File::exists(base_path($url)) && !empty($config['parent']) && $themeDir !== $config['parent']) {
|
||||
$parentUrl = $urlPath($config['parent'], $path);
|
||||
// If found in the parent, return it
|
||||
if (File::exists(base_path($parentUrl))) {
|
||||
return Url::asset($parentUrl);
|
||||
}
|
||||
}
|
||||
|
||||
// Default to returning the current theme's url
|
||||
return Url::asset($url);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a value from the theme configuration file by its name.
|
||||
*/
|
||||
public function getConfigValue(string $name, mixed $default = null): mixed
|
||||
{
|
||||
return array_get($this->getConfig(), $name, $default);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array value from the theme configuration file by its name.
|
||||
* If the value is a string, it is treated as a YAML file and loaded.
|
||||
*/
|
||||
public function getConfigArray(string $name): array
|
||||
{
|
||||
$result = array_get($this->getConfig(), $name, []);
|
||||
|
||||
if (is_string($result)) {
|
||||
$fileName = File::symbolizePath($result);
|
||||
|
||||
if (File::isLocalPath($fileName)) {
|
||||
$path = $fileName;
|
||||
}
|
||||
else {
|
||||
$path = $this->getPath().'/'.$result;
|
||||
}
|
||||
|
||||
if (!File::exists($path)) {
|
||||
throw new ApplicationException('Path does not exist: '.$path);
|
||||
}
|
||||
|
||||
$result = Yaml::parseFile($path);
|
||||
}
|
||||
|
||||
return (array) $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes to the theme.yaml file with the supplied array values.
|
||||
*
|
||||
* @throws ApplicationException if the theme.yaml file does not exist.
|
||||
*/
|
||||
public function writeConfig(array $values = [], bool $overwrite = false): void
|
||||
{
|
||||
if (!$overwrite) {
|
||||
$values = $values + (array) $this->getConfig();
|
||||
}
|
||||
|
||||
$path = $this->getPath().'/theme.yaml';
|
||||
if (!File::exists($path)) {
|
||||
throw new ApplicationException('Path does not exist: ' . $path);
|
||||
}
|
||||
|
||||
$contents = Yaml::render($values);
|
||||
File::put($path, $contents);
|
||||
$this->configCache = $values;
|
||||
|
||||
self::resetCache();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the theme preview image URL.
|
||||
* If the image file doesn't exist returns the placeholder image URL.
|
||||
*/
|
||||
public function getPreviewImageUrl(): string
|
||||
{
|
||||
$previewPath = $this->getConfigValue('previewImage', 'assets/images/theme-preview.png');
|
||||
|
||||
if (File::exists($this->getPath() . '/' . $previewPath)) {
|
||||
return Url::asset('themes/' . $this->getDirName() . '/' . $previewPath);
|
||||
}
|
||||
|
||||
return Url::asset('modules/cms/assets/images/default-theme-preview.png');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets any memory or cache involved with the active or edit theme.
|
||||
*/
|
||||
public static function resetCache(bool $memoryOnly = false): void
|
||||
{
|
||||
self::$activeThemeCache = false;
|
||||
self::$editThemeCache = false;
|
||||
|
||||
ThemeData::flushCache();
|
||||
|
||||
// Sometimes it may be desired to only clear the local cache of the active / edit themes instead of the persistent cache
|
||||
if (!$memoryOnly) {
|
||||
Cache::forget(self::ACTIVE_KEY);
|
||||
Cache::forget(self::EDIT_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if this theme has form fields that supply customization data.
|
||||
*/
|
||||
public function hasCustomData(): bool
|
||||
{
|
||||
return (bool) $this->getConfigValue('form', false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns data specific to this theme
|
||||
*/
|
||||
public function getCustomData(): ThemeData
|
||||
{
|
||||
return ThemeData::forTheme($this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove data specific to this theme
|
||||
*/
|
||||
public function removeCustomData(): bool
|
||||
{
|
||||
if ($this->hasCustomData()) {
|
||||
return $this->getCustomData()->delete();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the backend localizations provided by this theme and its ancestors.
|
||||
*/
|
||||
public function registerBackendLocalization(): void
|
||||
{
|
||||
$langPath = $this->getPath() . '/lang';
|
||||
|
||||
if (File::isDirectory($langPath)) {
|
||||
Lang::addNamespace('themes.' . $this->getDirName(), $langPath);
|
||||
}
|
||||
|
||||
// Check the parent theme if present
|
||||
$config = $this->getConfig();
|
||||
if (!empty($config['parent'])) {
|
||||
$langPath = themes_path($config['parent'] . '/lang');
|
||||
if (File::isDirectory($langPath)) {
|
||||
Lang::addNamespace('themes.' . $config['parent'], $langPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks to see if the database layer has been enabled
|
||||
*/
|
||||
public static function databaseLayerEnabled(): bool
|
||||
{
|
||||
$enableDbLayer = Config::get('cms.databaseTemplates', false);
|
||||
if (is_null($enableDbLayer)) {
|
||||
$enableDbLayer = !Config::get('app.debug', false);
|
||||
}
|
||||
|
||||
$hasDb = Cache::rememberForever('cms.databaseTemplates.hasTables', function () {
|
||||
return App::hasDatabaseTable('cms_theme_templates');
|
||||
});
|
||||
|
||||
return $enableDbLayer && $hasDb;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures this theme is registered as a Halcyon datasource.
|
||||
*/
|
||||
public function registerHalcyonDatasource(): void
|
||||
{
|
||||
$resolver = App::make('halcyon');
|
||||
if ($resolver->hasDatasource($this->dirName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$sources = [];
|
||||
if (static::databaseLayerEnabled()) {
|
||||
$sources['database'] = new DbDatasource($this->dirName, 'cms_theme_templates');
|
||||
}
|
||||
|
||||
$sources['filesystem'] = new FileDatasource($this->getPath(), App::make('files'));
|
||||
|
||||
$config = $this->getConfig();
|
||||
if (!empty($config['parent'])) {
|
||||
if (static::databaseLayerEnabled()) {
|
||||
$sources['parent-database'] = new DbDatasource($config['parent'], 'cms_theme_templates');
|
||||
}
|
||||
|
||||
$sources['parent-filesystem'] = new FileDatasource(themes_path($config['parent']), App::make('files'));
|
||||
}
|
||||
|
||||
$datasource = count($sources) > 1
|
||||
? new AutoDatasource($sources, 'halcyon-datasource-auto-' . $this->dirName)
|
||||
: array_shift($sources);
|
||||
|
||||
$resolver->addDatasource($this->dirName, $datasource);
|
||||
|
||||
/**
|
||||
* @event cms.theme.registerHalcyonDatasource
|
||||
* Fires immediately after the theme's Datasource has been registered.
|
||||
*
|
||||
* Allows for extension of the theme Halcyon Datasource, example usage:
|
||||
*
|
||||
* use Cms\Classes\Theme;
|
||||
* use Winter\Storm\Halcyon\Datasource\Resolver;
|
||||
*
|
||||
* Event::listen('cms.theme.registerHalcyonDatasource', function (Theme $theme, Resolver $resolver) {
|
||||
* $resolver->addDatasource($theme->getDirName(), new AutoDatasource([
|
||||
* 'theme' => $theme->getDatasource(),
|
||||
* 'example' => new ExampleDatasource(),
|
||||
* ], 'example-autodatasource'));
|
||||
* });
|
||||
*
|
||||
*/
|
||||
Event::fire('cms.theme.registerHalcyonDatasource', [$this, $resolver]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the theme's datasource
|
||||
*/
|
||||
public function getDatasource(): DatasourceInterface
|
||||
{
|
||||
$resolver = App::make('halcyon');
|
||||
return $resolver->datasource($this->getDirName());
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements the getter functionality.
|
||||
*/
|
||||
public function __get($name)
|
||||
{
|
||||
if (in_array(strtolower($name), ['id', 'path', 'dirname', 'config', 'formconfig', 'previewimageurl'])) {
|
||||
$method = 'get'. ucfirst($name);
|
||||
return $this->$method();
|
||||
}
|
||||
|
||||
if ($this->hasCustomData()) {
|
||||
return $this->getCustomData()->{$name};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if an attribute exists on the object.
|
||||
*/
|
||||
public function __isset($key)
|
||||
{
|
||||
if (in_array(strtolower($key), ['id', 'path', 'dirname', 'config', 'formconfig', 'previewimageurl'])) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($this->hasCustomData()) {
|
||||
$theme = $this->getCustomData();
|
||||
return $theme->offsetExists($key);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
127
modules/cms/classes/ThemeManager.php
Normal file
127
modules/cms/classes/ThemeManager.php
Normal file
@@ -0,0 +1,127 @@
|
||||
<?php namespace Cms\Classes;
|
||||
|
||||
use File;
|
||||
use ApplicationException;
|
||||
use System\Models\Parameter;
|
||||
use Cms\Classes\Theme as CmsTheme;
|
||||
|
||||
/**
|
||||
* Theme manager
|
||||
*
|
||||
* @package winter\wn-cms-module
|
||||
* @author Alexey Bobkov, Samuel Georges
|
||||
*/
|
||||
class ThemeManager
|
||||
{
|
||||
use \Winter\Storm\Support\Traits\Singleton;
|
||||
|
||||
//
|
||||
// Gateway spawned
|
||||
//
|
||||
|
||||
/**
|
||||
* Returns a collection of themes installed via the update gateway
|
||||
* @return array
|
||||
*/
|
||||
public function getInstalled()
|
||||
{
|
||||
return Parameter::get('system::theme.history', []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a theme has ever been installed before.
|
||||
* @param string $name Theme code
|
||||
* @return boolean
|
||||
*/
|
||||
public function isInstalled($name)
|
||||
{
|
||||
return array_key_exists($name, Parameter::get('system::theme.history', []));
|
||||
}
|
||||
|
||||
/**
|
||||
* Flags a theme as being installed, so it is not downloaded twice.
|
||||
* @param string $code Theme code
|
||||
* @param string|null $dirName
|
||||
*/
|
||||
public function setInstalled($code, $dirName = null)
|
||||
{
|
||||
if (!$dirName) {
|
||||
$dirName = strtolower(str_replace('.', '-', $code));
|
||||
}
|
||||
|
||||
$history = Parameter::get('system::theme.history', []);
|
||||
$history[$code] = $dirName;
|
||||
Parameter::set('system::theme.history', $history);
|
||||
}
|
||||
|
||||
/**
|
||||
* Flags a theme as being uninstalled.
|
||||
* @param string $code Theme code
|
||||
*/
|
||||
public function setUninstalled($code)
|
||||
{
|
||||
$history = Parameter::get('system::theme.history', []);
|
||||
if (array_key_exists($code, $history)) {
|
||||
unset($history[$code]);
|
||||
}
|
||||
|
||||
Parameter::set('system::theme.history', $history);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an installed theme's code from it's dirname.
|
||||
* @return string
|
||||
*/
|
||||
public function findByDirName($dirName)
|
||||
{
|
||||
$installed = $this->getInstalled();
|
||||
foreach ($installed as $code => $name) {
|
||||
if ($dirName == $name) {
|
||||
return $code;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
//
|
||||
// Management
|
||||
//
|
||||
|
||||
/**
|
||||
* Completely delete a theme from the system.
|
||||
* @param string $theme Theme code/namespace
|
||||
* @return void
|
||||
*/
|
||||
public function deleteTheme($theme)
|
||||
{
|
||||
if (!$theme) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (is_string($theme)) {
|
||||
$theme = CmsTheme::load($theme);
|
||||
}
|
||||
|
||||
if ($theme->isActiveTheme()) {
|
||||
throw new ApplicationException(trans('cms::lang.theme.delete_active_theme_failed'));
|
||||
}
|
||||
|
||||
$theme->removeCustomData();
|
||||
|
||||
/*
|
||||
* Delete from file system
|
||||
*/
|
||||
$themePath = $theme->getPath();
|
||||
if (File::isDirectory($themePath)) {
|
||||
File::deleteDirectory($themePath);
|
||||
}
|
||||
|
||||
/*
|
||||
* Set uninstalled
|
||||
*/
|
||||
if ($themeCode = $this->findByDirName($theme->getDirName())) {
|
||||
$this->setUninstalled($themeCode);
|
||||
}
|
||||
}
|
||||
}
|
||||
26
modules/cms/classes/asset/fields.yaml
Normal file
26
modules/cms/classes/asset/fields.yaml
Normal file
@@ -0,0 +1,26 @@
|
||||
# ===================================
|
||||
# Form Field Definitions
|
||||
# ===================================
|
||||
|
||||
fields:
|
||||
fileName:
|
||||
label: cms::lang.editor.filename
|
||||
attributes:
|
||||
default-focus: 1
|
||||
|
||||
toolbar:
|
||||
type: partial
|
||||
path: content_toolbar
|
||||
cssClass: collapse-visible
|
||||
|
||||
tabs:
|
||||
cssClass: master-area
|
||||
|
||||
secondaryTabs:
|
||||
stretch: true
|
||||
fields:
|
||||
content:
|
||||
tab: cms::lang.editor.content
|
||||
stretch: true
|
||||
type: codeeditor
|
||||
language: css
|
||||
28
modules/cms/classes/content/fields.yaml
Normal file
28
modules/cms/classes/content/fields.yaml
Normal file
@@ -0,0 +1,28 @@
|
||||
# ===================================
|
||||
# Form Field Definitions
|
||||
# ===================================
|
||||
|
||||
fields:
|
||||
fileName:
|
||||
label: cms::lang.editor.filename
|
||||
attributes:
|
||||
default-focus: 1
|
||||
|
||||
toolbar:
|
||||
type: partial
|
||||
path: content_toolbar
|
||||
cssClass: collapse-visible
|
||||
|
||||
components: Cms\FormWidgets\Components
|
||||
|
||||
tabs:
|
||||
cssClass: master-area
|
||||
|
||||
secondaryTabs:
|
||||
stretch: true
|
||||
fields:
|
||||
markup:
|
||||
tab: cms::lang.editor.content
|
||||
stretch: true
|
||||
type: codeeditor
|
||||
language: html
|
||||
45
modules/cms/classes/layout/fields.yaml
Normal file
45
modules/cms/classes/layout/fields.yaml
Normal file
@@ -0,0 +1,45 @@
|
||||
# ===================================
|
||||
# Form Field Definitions
|
||||
# ===================================
|
||||
|
||||
fields:
|
||||
fileName:
|
||||
label: cms::lang.editor.filename
|
||||
span: left
|
||||
attributes:
|
||||
default-focus: 1
|
||||
|
||||
settings[description]:
|
||||
label: cms::lang.editor.description
|
||||
span: right
|
||||
|
||||
toolbar:
|
||||
type: partial
|
||||
path: layout_toolbar
|
||||
cssClass: collapse-visible
|
||||
|
||||
components: Cms\FormWidgets\Components
|
||||
|
||||
tabs:
|
||||
cssClass: master-area
|
||||
|
||||
secondaryTabs:
|
||||
stretch: true
|
||||
fields:
|
||||
markup:
|
||||
tab: cms::lang.editor.markup
|
||||
stretch: true
|
||||
type: codeeditor
|
||||
language: twig
|
||||
|
||||
safemode_notice:
|
||||
tab: cms::lang.editor.code
|
||||
type: partial
|
||||
hidden: true
|
||||
cssClass: p-b-0
|
||||
|
||||
code:
|
||||
tab: cms::lang.editor.code
|
||||
stretch: true
|
||||
type: codeeditor
|
||||
language: php
|
||||
87
modules/cms/classes/page/fields.yaml
Normal file
87
modules/cms/classes/page/fields.yaml
Normal file
@@ -0,0 +1,87 @@
|
||||
# ===================================
|
||||
# Form Field Definitions
|
||||
# ===================================
|
||||
|
||||
fields:
|
||||
settings[title]:
|
||||
span: left
|
||||
label: cms::lang.editor.title
|
||||
placeholder: cms::lang.editor.new_title
|
||||
attributes:
|
||||
default-focus: 1
|
||||
|
||||
settings[url]:
|
||||
span: right
|
||||
placeholder: /
|
||||
label: cms::lang.editor.url
|
||||
preset:
|
||||
field: settings[title]
|
||||
type: url
|
||||
|
||||
toolbar:
|
||||
type: partial
|
||||
path: page_toolbar
|
||||
cssClass: collapse-visible
|
||||
|
||||
components: Cms\FormWidgets\Components
|
||||
|
||||
tabs:
|
||||
cssClass: master-area
|
||||
fields:
|
||||
fileName:
|
||||
tab: cms::lang.editor.settings
|
||||
span: left
|
||||
label: cms::lang.editor.filename
|
||||
preset:
|
||||
field: settings[title]
|
||||
type: file
|
||||
|
||||
settings[layout]:
|
||||
tab: cms::lang.editor.settings
|
||||
span: right
|
||||
label: cms::lang.editor.layout
|
||||
type: dropdown
|
||||
options: getLayoutOptions
|
||||
|
||||
settings[description]:
|
||||
tab: cms::lang.editor.settings
|
||||
label: cms::lang.editor.description
|
||||
type: textarea
|
||||
size: tiny
|
||||
|
||||
settings[meta_title]:
|
||||
tab: cms::lang.editor.meta
|
||||
label: cms::lang.editor.meta_title
|
||||
|
||||
settings[meta_description]:
|
||||
tab: cms::lang.editor.meta
|
||||
label: cms::lang.editor.meta_description
|
||||
type: textarea
|
||||
size: tiny
|
||||
|
||||
settings[is_hidden]:
|
||||
tab: cms::lang.editor.settings
|
||||
label: cms::lang.editor.hidden
|
||||
type: checkbox
|
||||
comment: cms::lang.editor.hidden_comment
|
||||
|
||||
secondaryTabs:
|
||||
stretch: true
|
||||
fields:
|
||||
markup:
|
||||
tab: cms::lang.editor.markup
|
||||
stretch: true
|
||||
type: codeeditor
|
||||
language: twig
|
||||
|
||||
safemode_notice:
|
||||
tab: cms::lang.editor.code
|
||||
type: partial
|
||||
hidden: true
|
||||
cssClass: p-b-0
|
||||
|
||||
code:
|
||||
tab: cms::lang.editor.code
|
||||
stretch: true
|
||||
type: codeeditor
|
||||
language: php
|
||||
45
modules/cms/classes/partial/fields.yaml
Normal file
45
modules/cms/classes/partial/fields.yaml
Normal file
@@ -0,0 +1,45 @@
|
||||
# ===================================
|
||||
# Form Field Definitions
|
||||
# ===================================
|
||||
|
||||
fields:
|
||||
fileName:
|
||||
span: left
|
||||
label: cms::lang.editor.filename
|
||||
attributes:
|
||||
default-focus: 1
|
||||
|
||||
settings[description]:
|
||||
span: right
|
||||
label: cms::lang.editor.description
|
||||
|
||||
toolbar:
|
||||
type: partial
|
||||
path: partial_toolbar
|
||||
cssClass: collapse-visible
|
||||
|
||||
components: Cms\FormWidgets\Components
|
||||
|
||||
tabs:
|
||||
cssClass: master-area
|
||||
|
||||
secondaryTabs:
|
||||
stretch: true
|
||||
fields:
|
||||
markup:
|
||||
tab: cms::lang.editor.markup
|
||||
stretch: true
|
||||
type: codeeditor
|
||||
language: twig
|
||||
|
||||
safemode_notice:
|
||||
tab: cms::lang.editor.code
|
||||
type: partial
|
||||
hidden: true
|
||||
cssClass: p-b-0
|
||||
|
||||
code:
|
||||
tab: cms::lang.editor.code
|
||||
stretch: true
|
||||
type: codeeditor
|
||||
language: php
|
||||
58
modules/cms/classes/theme/fields.yaml
Normal file
58
modules/cms/classes/theme/fields.yaml
Normal file
@@ -0,0 +1,58 @@
|
||||
# ===================================
|
||||
# Form Field Definitions
|
||||
# ===================================
|
||||
|
||||
tabs:
|
||||
defaultTab: cms::lang.theme.default_tab
|
||||
fields:
|
||||
|
||||
name:
|
||||
label: cms::lang.theme.name_label
|
||||
placeholder: cms::lang.theme.name_create_placeholder
|
||||
span: auto
|
||||
required: true
|
||||
attributes:
|
||||
default-focus: 1
|
||||
|
||||
dir_name@create:
|
||||
label: cms::lang.theme.dir_name_label
|
||||
placeholder: cms::lang.theme.dir_name_create_label
|
||||
span: auto
|
||||
preset: name
|
||||
required: true
|
||||
|
||||
dir_name@update:
|
||||
label: cms::lang.theme.dir_name_label
|
||||
disabled: true
|
||||
span: auto
|
||||
|
||||
scaffold@create:
|
||||
label: cms::lang.theme.scaffold.label
|
||||
type: balloon-selector
|
||||
span: full
|
||||
required: true
|
||||
options:
|
||||
empty: cms::lang.theme.scaffold.empty
|
||||
less: cms::lang.theme.scaffold.less
|
||||
tailwind: cms::lang.theme.scaffold.tailwind
|
||||
default: less
|
||||
|
||||
description:
|
||||
label: cms::lang.theme.description_label
|
||||
placeholder: cms::lang.theme.description_placeholder
|
||||
type: textarea
|
||||
size: tiny
|
||||
|
||||
author:
|
||||
label: cms::lang.theme.author_label
|
||||
placeholder: cms::lang.theme.author_placeholder
|
||||
span: auto
|
||||
|
||||
homepage:
|
||||
label: cms::lang.theme.homepage_label
|
||||
placeholder: cms::lang.theme.homepage_placeholder
|
||||
span: auto
|
||||
|
||||
code:
|
||||
label: cms::lang.theme.code_label
|
||||
placeholder: cms::lang.theme.code_placeholder
|
||||
190
modules/cms/components/Resources.php
Normal file
190
modules/cms/components/Resources.php
Normal file
@@ -0,0 +1,190 @@
|
||||
<?php namespace Cms\Components;
|
||||
|
||||
use File;
|
||||
use Cms\Classes\ComponentBase;
|
||||
use System\Classes\CombineAssets;
|
||||
|
||||
/**
|
||||
* Resources component
|
||||
*/
|
||||
class Resources extends ComponentBase
|
||||
{
|
||||
/**
|
||||
* @var string The default JavaScript directory
|
||||
*/
|
||||
public $jsDir = 'js';
|
||||
|
||||
/**
|
||||
* @var string The default CSS directory
|
||||
*/
|
||||
public $cssDir = 'css';
|
||||
|
||||
/**
|
||||
* @var string The default LESS directory
|
||||
*/
|
||||
public $lessDir = 'less';
|
||||
|
||||
/**
|
||||
* @var string The default SASS directory
|
||||
*/
|
||||
public $sassDir = 'sass';
|
||||
|
||||
/**
|
||||
* @return array
|
||||
*/
|
||||
public function componentDetails()
|
||||
{
|
||||
return [
|
||||
'name' => 'Resources',
|
||||
'description' => 'Easily reference theme assets for inclusion on a page.',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array
|
||||
*/
|
||||
public function defineProperties()
|
||||
{
|
||||
return [
|
||||
'js' => [
|
||||
'title' => 'JavaScript',
|
||||
'description' => 'JavaScript file(s) in the assets/js folder',
|
||||
'type' => 'stringList',
|
||||
'showExternalParam' => false
|
||||
],
|
||||
'less' => [
|
||||
'title' => 'LESS',
|
||||
'description' => 'LESS file(s) in the assets/less folder',
|
||||
'type' => 'stringList',
|
||||
'showExternalParam' => false
|
||||
],
|
||||
'sass' => [
|
||||
'title' => 'SASS',
|
||||
'description' => 'SASS file(s) in the assets/sass folder',
|
||||
'type' => 'stringList',
|
||||
'showExternalParam' => false
|
||||
],
|
||||
'css' => [
|
||||
'title' => 'CSS',
|
||||
'description' => 'Stylesheet file(s) in the assets/css folder',
|
||||
'type' => 'stringList',
|
||||
'showExternalParam' => false
|
||||
],
|
||||
'vars' => [
|
||||
'title' => 'Variables',
|
||||
'description' => 'Page variables name(s) and value(s)',
|
||||
'type' => 'dictionary',
|
||||
'showExternalParam' => false
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
public function init()
|
||||
{
|
||||
$this->assetPath = $this->guessAssetPath();
|
||||
$this->jsDir = $this->guessAssetDirectory(['js', 'javascript'], $this->jsDir);
|
||||
$this->sassDir = $this->guessAssetDirectory(['sass', 'scss'], $this->sassDir);
|
||||
}
|
||||
|
||||
public function onRun()
|
||||
{
|
||||
/*
|
||||
* JavaScript
|
||||
*/
|
||||
$js = [];
|
||||
if ($assets = $this->property('js')) {
|
||||
$js += array_map([$this, 'prefixJs'], (array) $assets);
|
||||
}
|
||||
|
||||
/*
|
||||
* LESS
|
||||
*/
|
||||
$less = [];
|
||||
if ($assets = $this->property('less')) {
|
||||
$less += array_map([$this, 'prefixLess'], (array) $assets);
|
||||
}
|
||||
|
||||
/*
|
||||
* SASS
|
||||
*/
|
||||
$sass = [];
|
||||
if ($assets = $this->property('sass')) {
|
||||
$sass += array_map([$this, 'prefixSass'], (array) $assets);
|
||||
}
|
||||
|
||||
/*
|
||||
* CSS
|
||||
*/
|
||||
$css = [];
|
||||
if ($assets = $this->property('css')) {
|
||||
$css += array_map([$this, 'prefixCss'], (array) $assets);
|
||||
}
|
||||
|
||||
if (count($js)) {
|
||||
$this->addJs(CombineAssets::combine($js, $this->assetPath));
|
||||
}
|
||||
|
||||
if (count($less)) {
|
||||
$this->addCss(CombineAssets::combine($less, $this->assetPath));
|
||||
}
|
||||
|
||||
if (count($sass)) {
|
||||
$this->addCss(CombineAssets::combine($sass, $this->assetPath));
|
||||
}
|
||||
|
||||
if (count($css)) {
|
||||
$this->addCss(CombineAssets::combine($css, $this->assetPath));
|
||||
}
|
||||
|
||||
/*
|
||||
* Variables
|
||||
*/
|
||||
if ($vars = $this->property('vars')) {
|
||||
foreach ((array) $vars as $key => $value) {
|
||||
$this->page[$key] = $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected function prefixJs($value)
|
||||
{
|
||||
return $this->jsDir.'/'.trim($value);
|
||||
}
|
||||
|
||||
protected function prefixCss($value)
|
||||
{
|
||||
return $this->cssDir.'/'.trim($value);
|
||||
}
|
||||
|
||||
protected function prefixLess($value)
|
||||
{
|
||||
return $this->lessDir.'/'.trim($value);
|
||||
}
|
||||
|
||||
protected function prefixSass($value)
|
||||
{
|
||||
return $this->sassDir.'/'.trim($value);
|
||||
}
|
||||
|
||||
protected function guessAssetDirectory(array $possible, $default = null)
|
||||
{
|
||||
foreach ($possible as $option) {
|
||||
if (File::isDirectory($this->assetPath.'/'.$option)) {
|
||||
return $option;
|
||||
}
|
||||
}
|
||||
|
||||
return $default;
|
||||
}
|
||||
|
||||
protected function guessAssetPath()
|
||||
{
|
||||
$baseTheme = themes_path().'/'.$this->getTheme()->getDirName();
|
||||
|
||||
if (File::isDirectory($baseTheme.'/assets')) {
|
||||
return $baseTheme.'/assets';
|
||||
}
|
||||
|
||||
return $baseTheme.'/resources';
|
||||
}
|
||||
}
|
||||
33
modules/cms/components/SoftComponent.php
Normal file
33
modules/cms/components/SoftComponent.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php namespace Cms\Components;
|
||||
|
||||
use Cms\Classes\ComponentBase;
|
||||
|
||||
class SoftComponent extends ComponentBase
|
||||
{
|
||||
/**
|
||||
* @var string Message that is shown with this component.
|
||||
*/
|
||||
protected $message;
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function __construct($properties)
|
||||
{
|
||||
$this->componentCssClass = 'warning-component';
|
||||
$this->inspectorEnabled = false;
|
||||
|
||||
parent::__construct(null, $properties);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array
|
||||
*/
|
||||
public function componentDetails()
|
||||
{
|
||||
return [
|
||||
'name' => 'cms::lang.component.soft_component',
|
||||
'description' => 'cms::lang.component.soft_component_description'
|
||||
];
|
||||
}
|
||||
}
|
||||
34
modules/cms/components/UnknownComponent.php
Normal file
34
modules/cms/components/UnknownComponent.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php namespace Cms\Components;
|
||||
|
||||
use Cms\Classes\ComponentBase;
|
||||
|
||||
class UnknownComponent extends ComponentBase
|
||||
{
|
||||
/**
|
||||
* @var string Error message that is shown with this error component.
|
||||
*/
|
||||
protected $errorMessage;
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function __construct($cmsObject, $properties, $errorMessage)
|
||||
{
|
||||
$this->errorMessage = $errorMessage;
|
||||
$this->componentCssClass = 'error-component';
|
||||
$this->inspectorEnabled = false;
|
||||
|
||||
parent::__construct($cmsObject, $properties);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array
|
||||
*/
|
||||
public function componentDetails()
|
||||
{
|
||||
return [
|
||||
'name' => 'Unknown component',
|
||||
'description' => $this->errorMessage
|
||||
];
|
||||
}
|
||||
}
|
||||
83
modules/cms/components/ViewBag.php
Normal file
83
modules/cms/components/ViewBag.php
Normal file
@@ -0,0 +1,83 @@
|
||||
<?php namespace Cms\Components;
|
||||
|
||||
use Cms\Classes\ComponentBase;
|
||||
|
||||
/**
|
||||
* The view bag stores custom template properties.
|
||||
* This is a hidden component ignored by the back-end UI.
|
||||
*
|
||||
* @package winter\wn-cms-module
|
||||
* @author Alexey Bobkov, Samuel Georges
|
||||
*/
|
||||
class ViewBag extends ComponentBase
|
||||
{
|
||||
/**
|
||||
* @var boolean This component is hidden from the back-end UI.
|
||||
*/
|
||||
public $isHidden = true;
|
||||
|
||||
/**
|
||||
* @return array
|
||||
*/
|
||||
public function componentDetails()
|
||||
{
|
||||
return [
|
||||
'name' => 'viewBag',
|
||||
'description' => 'Stores custom template properties.'
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $properties
|
||||
* @return array
|
||||
*/
|
||||
public function validateProperties(array $properties)
|
||||
{
|
||||
return $properties;
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements the getter functionality.
|
||||
* @param string $name
|
||||
* @return void
|
||||
*/
|
||||
public function __get($name)
|
||||
{
|
||||
if (array_key_exists($name, $this->properties)) {
|
||||
return $this->properties[$name];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if an attribute exists on the object.
|
||||
* @param string $key
|
||||
* @return bool
|
||||
*/
|
||||
public function __isset($key)
|
||||
{
|
||||
if (array_key_exists($key, $this->properties)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array
|
||||
*/
|
||||
public function defineProperties()
|
||||
{
|
||||
$result = [];
|
||||
|
||||
foreach ($this->properties as $name => $value) {
|
||||
$result[$name] = [
|
||||
'title' => $name,
|
||||
'type' => 'string'
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
39
modules/cms/composer.json
Normal file
39
modules/cms/composer.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "winter/wn-cms-module",
|
||||
"type": "winter-module",
|
||||
"description": "CMS module for Winter CMS",
|
||||
"homepage": "https://wintercms.com",
|
||||
"keywords": ["winter cms", "winter", "cms"],
|
||||
"license": "MIT",
|
||||
"authors": [
|
||||
{
|
||||
"name": "Alexey Bobkov",
|
||||
"email": "aleksey.bobkov@gmail.com",
|
||||
"role": "Original Author"
|
||||
},
|
||||
{
|
||||
"name": "Samuel Georges",
|
||||
"email": "daftspunky@gmail.com",
|
||||
"role": "Original Author"
|
||||
},
|
||||
{
|
||||
"name": "Luke Towers",
|
||||
"email": "wintercms@luketowers.ca",
|
||||
"role": "Lead Maintainer"
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"php": ">=8.1",
|
||||
"composer/installers": "~1.11.0",
|
||||
"laravel/framework": "^9.1"
|
||||
},
|
||||
"replace": {
|
||||
"october/cms": "1.1.*"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Cms\\": ""
|
||||
}
|
||||
},
|
||||
"minimum-stability": "dev"
|
||||
}
|
||||
54
modules/cms/console/CreateComponent.php
Normal file
54
modules/cms/console/CreateComponent.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php namespace Cms\Console;
|
||||
|
||||
use System\Console\BaseScaffoldCommand;
|
||||
|
||||
class CreateComponent extends BaseScaffoldCommand
|
||||
{
|
||||
/**
|
||||
* The default command name for lazy loading.
|
||||
*
|
||||
* @var string|null
|
||||
*/
|
||||
protected static $defaultName = 'create:component';
|
||||
|
||||
/**
|
||||
* The name and signature of this command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'create:component
|
||||
{plugin : The name of the plugin. <info>(eg: Winter.Blog)</info>}
|
||||
{component : The name of the component to generate. <info>(eg: Posts)</info>}
|
||||
{--force : Overwrite existing files with generated files.}
|
||||
{--uninspiring : Disable inspirational quotes}
|
||||
';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Creates a new plugin component.';
|
||||
|
||||
/**
|
||||
* The type of class being generated.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $type = 'Component';
|
||||
|
||||
/**
|
||||
* @var string The argument that the generated class name comes from
|
||||
*/
|
||||
protected $nameFrom = 'component';
|
||||
|
||||
/**
|
||||
* A mapping of stub to generated file.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $stubs = [
|
||||
'scaffold/component/component.stub' => 'components/{{studly_name}}.php',
|
||||
'scaffold/component/default.stub' => 'components/{{lower_name}}/default.htm',
|
||||
];
|
||||
}
|
||||
217
modules/cms/console/CreateTheme.php
Normal file
217
modules/cms/console/CreateTheme.php
Normal file
@@ -0,0 +1,217 @@
|
||||
<?php namespace Cms\Console;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use System\Classes\Asset\PackageManager;
|
||||
use Winter\Storm\Exception\SystemException;
|
||||
use Winter\Storm\Scaffold\GeneratorCommand;
|
||||
|
||||
class CreateTheme extends GeneratorCommand
|
||||
{
|
||||
/**
|
||||
* @var string|null The default command name for lazy loading.
|
||||
*/
|
||||
protected static $defaultName = 'create:theme';
|
||||
|
||||
/**
|
||||
* @var string The name and signature of this command.
|
||||
*/
|
||||
protected $signature = 'create:theme
|
||||
{theme : The name of the theme to create. <info>(eg: MyTheme)</info>}
|
||||
{scaffold? : The base theme scaffold to use <info>(eg: less, tailwind)</info>}
|
||||
{--f|force : Overwrite existing files with generated files.}
|
||||
{--uninspiring : Disable inspirational quotes}
|
||||
';
|
||||
|
||||
/**
|
||||
* @var string The console command description.
|
||||
*/
|
||||
protected $description = 'Creates a new theme.';
|
||||
|
||||
/**
|
||||
* @var string The type of class being generated.
|
||||
*/
|
||||
protected $type = 'Theme';
|
||||
|
||||
/**
|
||||
* @var string The argument that the generated class name comes from
|
||||
*/
|
||||
protected $nameFrom = 'theme';
|
||||
|
||||
/**
|
||||
* @var string The scaffold that we are building
|
||||
*/
|
||||
protected string $scaffold;
|
||||
|
||||
/**
|
||||
* @var array Available theme scaffolds and their types
|
||||
*/
|
||||
protected $themeScaffolds = [
|
||||
'less' => [
|
||||
'scaffold/theme/less/assets/js/app.stub' => 'assets/js/app.js',
|
||||
'scaffold/theme/less/assets/less/theme.stub' => 'assets/less/theme.less',
|
||||
'scaffold/theme/less/layouts/default.stub' => 'layouts/default.htm',
|
||||
'scaffold/theme/less/pages/404.stub' => 'pages/404.htm',
|
||||
'scaffold/theme/less/pages/error.stub' => 'pages/error.htm',
|
||||
'scaffold/theme/less/pages/home.stub' => 'pages/home.htm',
|
||||
'scaffold/theme/less/partials/meta/seo.stub' => 'partials/meta/seo.htm',
|
||||
'scaffold/theme/less/partials/meta/styles.stub' => 'partials/meta/styles.htm',
|
||||
'scaffold/theme/less/partials/site/header.stub' => 'partials/site/header.htm',
|
||||
'scaffold/theme/less/partials/site/footer.stub' => 'partials/site/footer.htm',
|
||||
'scaffold/theme/less/theme.stub' => 'theme.yaml',
|
||||
'scaffold/theme/less/version.stub' => 'version.yaml',
|
||||
],
|
||||
'tailwind' => [
|
||||
'scaffold/theme/tailwind/lang/en/lang.stub' => 'lang/en/lang.php',
|
||||
'scaffold/theme/tailwind/layouts/default.stub' => 'layouts/default.htm',
|
||||
'scaffold/theme/tailwind/pages/404.stub' => 'pages/404.htm',
|
||||
'scaffold/theme/tailwind/pages/error.stub' => 'pages/error.htm',
|
||||
'scaffold/theme/tailwind/pages/home.stub' => 'pages/home.htm',
|
||||
'scaffold/theme/tailwind/partials/meta/seo.stub' => 'partials/meta/seo.htm',
|
||||
'scaffold/theme/tailwind/partials/meta/styles.stub' => 'partials/meta/styles.htm',
|
||||
'scaffold/theme/tailwind/partials/site/header.stub' => 'partials/site/header.htm',
|
||||
'scaffold/theme/tailwind/partials/site/footer.stub' => 'partials/site/footer.htm',
|
||||
'scaffold/theme/tailwind/.gitignore.stub' => '.gitignore',
|
||||
'scaffold/theme/tailwind/README.stub' => 'README.md',
|
||||
'scaffold/theme/tailwind/theme.stub' => 'theme.yaml',
|
||||
'scaffold/theme/tailwind/version.stub' => 'version.yaml',
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the desired class name from the input.
|
||||
*/
|
||||
protected function getNameInput(): string
|
||||
{
|
||||
return str_slug(parent::getNameInput());
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare variables for stubs.
|
||||
*/
|
||||
protected function prepareVars(): array
|
||||
{
|
||||
$this->scaffold = $this->argument('scaffold') ?? 'tailwind';
|
||||
|
||||
$validOptions = $this->suggestScaffoldValues();
|
||||
if (!in_array($this->scaffold, $validOptions)) {
|
||||
throw new InvalidArgumentException("$this->scaffold is not an available theme scaffold type (Available types: " . implode(', ', $validOptions) . ')');
|
||||
}
|
||||
$this->stubs = $this->themeScaffolds[$this->scaffold];
|
||||
|
||||
return [
|
||||
'code' => $this->getNameInput(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto suggest valid theme scaffold values
|
||||
*/
|
||||
public function suggestScaffoldValues(): array
|
||||
{
|
||||
return array_keys($this->themeScaffolds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the plugin path from the input.
|
||||
*/
|
||||
protected function getDestinationPath(): string
|
||||
{
|
||||
return themes_path($this->getNameInput());
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a single stub.
|
||||
*
|
||||
* @param string $stubName The source filename for the stub.
|
||||
*/
|
||||
public function makeStub($stubName)
|
||||
{
|
||||
if (!isset($this->stubs[$stubName])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$sourceFile = $this->getSourcePath() . '/' . $stubName;
|
||||
$destinationFile = $this->getDestinationForStub($stubName);
|
||||
$destinationContent = $this->files->get($sourceFile);
|
||||
|
||||
/*
|
||||
* Parse each variable in to the destination content and path
|
||||
* @NOTE: CANNOT USE TWIG AS IT WOULD CONFLICT WITH THE TWIG TEMPLATES THEMSELVES
|
||||
*/
|
||||
foreach ($this->vars as $key => $var) {
|
||||
$destinationContent = str_replace('{{' . $key . '}}', $var, $destinationContent);
|
||||
$destinationFile = str_replace('{{' . $key . '}}', $var, $destinationFile);
|
||||
}
|
||||
|
||||
$this->makeDirectory($destinationFile);
|
||||
|
||||
$this->files->put($destinationFile, $destinationContent);
|
||||
}
|
||||
|
||||
public function makeStubs(): void
|
||||
{
|
||||
parent::makeStubs();
|
||||
|
||||
if ($this->scaffold === 'tailwind') {
|
||||
// @TODO: allow support for mix here
|
||||
$this->tailwindPostCreate('vite');
|
||||
}
|
||||
}
|
||||
|
||||
protected function tailwindPostCreate(string $processor): void
|
||||
{
|
||||
if ($this->call('npm:version', ['--silent' => true, '--compatible' => true]) !== 0) {
|
||||
throw new SystemException(sprintf(
|
||||
'NPM is not installed or is outdated, please ensure NPM >= v7.0 is available and then manually set up %s.',
|
||||
$processor
|
||||
));
|
||||
}
|
||||
|
||||
$commands = [
|
||||
// Set up the vite config files
|
||||
$processor . ':create' => [
|
||||
'message' => 'Generating ' . $processor . ' + tailwind config...',
|
||||
'args' => [
|
||||
'packageName' => 'theme-' . $this->getNameInput(),
|
||||
'--no-interaction' => true,
|
||||
'--force' => true,
|
||||
'--silent' => true,
|
||||
'--tailwind' => true
|
||||
]
|
||||
],
|
||||
// Ensure all require packages are available for the new theme and add the new theme to our npm workspaces
|
||||
$processor . ':install' => [
|
||||
'message' => 'Installing NPM dependencies...',
|
||||
'args' => [
|
||||
'assetPackage' => ['theme-' . $this->getNameInput()],
|
||||
'--no-interaction' => true,
|
||||
'--silent' => false,
|
||||
'--disable-tty' => true
|
||||
]
|
||||
],
|
||||
// Run an initial compile to ensure styles are available for first load
|
||||
$processor . ':compile' => [
|
||||
'message' => 'Compiling your theme...',
|
||||
'args' => [
|
||||
'--package' => ['theme-' . $this->getNameInput()],
|
||||
'--no-interaction' => true,
|
||||
'--silent' => true,
|
||||
]
|
||||
]
|
||||
];
|
||||
|
||||
foreach ($commands as $command => $data) {
|
||||
$this->info($data['message']);
|
||||
|
||||
// Handle commands throwing errors
|
||||
if ($this->call($command, $data['args']) !== 0) {
|
||||
throw new SystemException(sprintf('Post create command `%s` failed, please review manually.', $command));
|
||||
}
|
||||
|
||||
// Force PackageManger to reset available packages
|
||||
if ($command === $processor . ':create') {
|
||||
PackageManager::forgetInstance();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
130
modules/cms/console/ThemeInstall.php
Normal file
130
modules/cms/console/ThemeInstall.php
Normal file
@@ -0,0 +1,130 @@
|
||||
<?php namespace Cms\Console;
|
||||
|
||||
use Cms\Classes\Theme;
|
||||
use Cms\Classes\ThemeManager;
|
||||
use File;
|
||||
use System\Classes\UpdateManager;
|
||||
use Winter\Storm\Console\Command;
|
||||
|
||||
/**
|
||||
* Console command to install a new theme.
|
||||
*
|
||||
* This adds a new theme by requesting it from the Winter marketplace.
|
||||
*
|
||||
* @package winter\wn-cms-module
|
||||
* @author Alexey Bobkov, Samuel Georges
|
||||
*/
|
||||
class ThemeInstall extends Command
|
||||
{
|
||||
/**
|
||||
* The console command name.
|
||||
* @var string
|
||||
*/
|
||||
protected $name = 'theme:install';
|
||||
|
||||
/**
|
||||
* @var string The name and signature of this command.
|
||||
*/
|
||||
protected $signature = 'theme:install
|
||||
{name : The name of the theme. <info>(eg: AuthorName.ThemeName)</info>}
|
||||
{dirName? : Destination directory name for the theme installation.}
|
||||
';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Install a theme from the Winter marketplace.';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
* @return void
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
$themeName = $this->argument('name');
|
||||
$argDirName = $this->argument('dirName');
|
||||
|
||||
if ($argDirName && $themeName == $argDirName) {
|
||||
$argDirName = null;
|
||||
}
|
||||
|
||||
if ($argDirName) {
|
||||
if (!Theme::isValidDirName($argDirName)) {
|
||||
return $this->error('Invalid destination directory name.');
|
||||
}
|
||||
|
||||
if (Theme::exists($argDirName)) {
|
||||
return $this->error(sprintf('A theme named %s already exists.', $argDirName));
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$themeManager = ThemeManager::instance();
|
||||
$updateManager = UpdateManager::instance();
|
||||
|
||||
$themeDetails = $updateManager->requestThemeDetails($themeName);
|
||||
|
||||
if ($themeManager->isInstalled($themeDetails['code'])) {
|
||||
return $this->error(sprintf('The theme %s is already installed.', $themeDetails['code']));
|
||||
}
|
||||
|
||||
if (Theme::exists($themeDetails['code'])) {
|
||||
return $this->error(sprintf('A theme named %s already exists.', $themeDetails['code']));
|
||||
}
|
||||
|
||||
$fields = ['Name', 'Description', 'Author', 'URL', ''];
|
||||
|
||||
$this->info(sprintf(
|
||||
implode(': %s'.PHP_EOL, $fields),
|
||||
$themeDetails['code'],
|
||||
$themeDetails['description'],
|
||||
$themeDetails['author'],
|
||||
$themeDetails['product_url']
|
||||
));
|
||||
|
||||
if (!$this->confirm('Do you wish to continue? [Y|n]', true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->info('Downloading theme...');
|
||||
$updateManager->downloadTheme($themeDetails['code'], $themeDetails['hash']);
|
||||
|
||||
$this->info('Extracting theme...');
|
||||
$updateManager->extractTheme($themeDetails['code'], $themeDetails['hash']);
|
||||
|
||||
$dirName = $this->themeCodeToDir($themeDetails['code']);
|
||||
|
||||
if ($argDirName) {
|
||||
/*
|
||||
* Move downloaded theme to a new directory.
|
||||
* Basically we're renaming it.
|
||||
*/
|
||||
File::move(themes_path().'/'.$dirName, themes_path().'/'.$argDirName);
|
||||
|
||||
/*
|
||||
* Let's make sure to unflag the 'old' theme as
|
||||
* installed so it can be re-installed later.
|
||||
*/
|
||||
$themeManager->setUninstalled($themeDetails['code']);
|
||||
|
||||
$dirName = $argDirName;
|
||||
}
|
||||
|
||||
$this->info(sprintf('The theme %s has been installed. (now %s)', $themeDetails['code'], $dirName));
|
||||
} catch (\Throwable $ex) {
|
||||
$this->error($ex->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Theme code to dir.
|
||||
*
|
||||
* @param string $themeCode
|
||||
* @return string
|
||||
*/
|
||||
protected function themeCodeToDir($themeCode)
|
||||
{
|
||||
return strtolower(str_replace('.', '-', $themeCode));
|
||||
}
|
||||
}
|
||||
66
modules/cms/console/ThemeList.php
Normal file
66
modules/cms/console/ThemeList.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php namespace Cms\Console;
|
||||
|
||||
use Cms\Classes\Theme;
|
||||
use Cms\Classes\ThemeManager;
|
||||
use System\Classes\UpdateManager;
|
||||
use Winter\Storm\Console\Command;
|
||||
|
||||
/**
|
||||
* Console command to list themes.
|
||||
*
|
||||
* This lists all the available themes in the system. It also shows the active theme.
|
||||
*
|
||||
* @package winter\wn-cms-module
|
||||
* @author Alexey Bobkov, Samuel Georges
|
||||
*/
|
||||
class ThemeList extends Command
|
||||
{
|
||||
/**
|
||||
* The console command name.
|
||||
*/
|
||||
protected $name = 'theme:list';
|
||||
|
||||
/**
|
||||
* @var string The name and signature of this command.
|
||||
*/
|
||||
protected $signature = 'theme:list
|
||||
{--m|include-marketplace : Include downloadable themes from the Winter marketplace.}
|
||||
';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*/
|
||||
protected $description = 'List available themes.';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
$themeManager = ThemeManager::instance();
|
||||
$updateManager = UpdateManager::instance();
|
||||
$results = [];
|
||||
|
||||
foreach (Theme::all() as $theme) {
|
||||
$results[] = [
|
||||
'code' => $theme->getId(),
|
||||
'is_active' => $theme->isActiveTheme() ? '<info>Yes</info>': '<fg=red>No</>',
|
||||
'is_installed' => '<info>Yes</info>',
|
||||
];
|
||||
}
|
||||
|
||||
if ($this->option('include-marketplace')) {
|
||||
// @TODO List everything in the marketplace - not just popular.
|
||||
$popularThemes = $updateManager->requestPopularProducts('theme');
|
||||
foreach ($popularThemes as $popularTheme) {
|
||||
$results[] = [
|
||||
'code' => $popularTheme['code'],
|
||||
'is_active' => '<fg=red>No</>',
|
||||
'is_installed' => $themeManager->isInstalled($popularTheme['code']) ? '<info>Yes</info>': '<fg=red>No</>',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$this->table(['Theme', 'Active', 'Installed'], $results);
|
||||
}
|
||||
}
|
||||
70
modules/cms/console/ThemeRemove.php
Normal file
70
modules/cms/console/ThemeRemove.php
Normal file
@@ -0,0 +1,70 @@
|
||||
<?php namespace Cms\Console;
|
||||
|
||||
use Cms\Classes\Theme;
|
||||
use Cms\Classes\ThemeManager;
|
||||
use Exception;
|
||||
use Winter\Storm\Console\Command;
|
||||
|
||||
/**
|
||||
* Console command to remove a theme.
|
||||
*
|
||||
* This completely deletes an existing theme, including all files and directories.
|
||||
*
|
||||
* @package winter\wn-cms-module
|
||||
* @author Alexey Bobkov, Samuel Georges
|
||||
*/
|
||||
class ThemeRemove extends Command
|
||||
{
|
||||
use \Illuminate\Console\ConfirmableTrait;
|
||||
|
||||
/**
|
||||
* The console command name.
|
||||
* @var string
|
||||
*/
|
||||
protected $name = 'theme:remove';
|
||||
|
||||
/**
|
||||
* @var string The name and signature of this command.
|
||||
*/
|
||||
protected $signature = 'theme:remove
|
||||
{name : The name of the theme to delete. <info>(eg: mytheme)</info>}
|
||||
{--f|force : Force the operation to run.}
|
||||
';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Delete an existing theme.';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
* @return void
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
$themeManager = ThemeManager::instance();
|
||||
$themeName = $this->argument('name');
|
||||
$themeExists = Theme::exists($themeName);
|
||||
|
||||
if (!$themeExists) {
|
||||
$themeName = strtolower(str_replace('.', '-', $themeName));
|
||||
$themeExists = Theme::exists($themeName);
|
||||
}
|
||||
|
||||
if (!$themeExists) {
|
||||
return $this->error(sprintf('The theme %s does not exist.', $themeName));
|
||||
}
|
||||
|
||||
if (!$this->confirmToProceed(sprintf('This will DELETE theme "%s" from the filesystem and database.', $themeName))) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$themeManager->deleteTheme($themeName);
|
||||
$this->info(sprintf('The theme %s has been deleted.', $themeName));
|
||||
} catch (Exception $ex) {
|
||||
$this->error($ex->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
234
modules/cms/console/ThemeSync.php
Normal file
234
modules/cms/console/ThemeSync.php
Normal file
@@ -0,0 +1,234 @@
|
||||
<?php namespace Cms\Console;
|
||||
|
||||
use Cms\Classes\Theme;
|
||||
use Event;
|
||||
use Exception;
|
||||
use Winter\Storm\Console\Command;
|
||||
|
||||
/**
|
||||
* Console command to sync a theme between the DB and Filesystem layers.
|
||||
*
|
||||
* theme:sync name --paths=file/to/sync.md,other/file/to/sync.md --target=filesystem --force
|
||||
*
|
||||
* - name defaults to the currently active theme
|
||||
* - --paths defaults to all paths within the theme, otherwise comma-separated list of paths relative to the theme directory
|
||||
* - --target defaults to "filesystem", the source will whichever of filesystem vs database is not the target
|
||||
* - --force bypasses the confirmation request
|
||||
*
|
||||
* @package winter\wn-cms-module
|
||||
* @author Luke Towers
|
||||
*/
|
||||
class ThemeSync extends Command
|
||||
{
|
||||
use \Illuminate\Console\ConfirmableTrait;
|
||||
|
||||
/**
|
||||
* The console command name.
|
||||
* @var string
|
||||
*/
|
||||
protected $name = 'theme:sync';
|
||||
|
||||
/**
|
||||
* @var string The name and signature of this command.
|
||||
*/
|
||||
protected $signature = 'theme:sync
|
||||
{name? : The name of the theme (directory name). Defaults to currently active theme.}
|
||||
{--paths= : Comma-separated specific paths (relative to provided theme directory) to specificaly sync. Default is all paths. You may use regular expressions.}
|
||||
{--target= : The target of the sync, the other will be used as the source. Defaults to "filesystem", can be "database"}
|
||||
{--f|force : Force the operation to run.}
|
||||
';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Sync an existing theme between the DB and Filesystem layers';
|
||||
|
||||
/**
|
||||
* @var \Cms\Classes\AutoDatasource The theme's AutoDatasource instance
|
||||
*/
|
||||
protected $datasource;
|
||||
|
||||
/**
|
||||
* @var string The datasource key that the sync is targeting
|
||||
*/
|
||||
protected $target;
|
||||
|
||||
/**
|
||||
* @var string The datasource key that the sync is sourcing from
|
||||
*/
|
||||
protected $source;
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
* @return void
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
// Check to see if the application even uses a database
|
||||
if (!$this->laravel->hasDatabase()) {
|
||||
return $this->error("The application is not using a database.");
|
||||
}
|
||||
|
||||
// Check to see if the DB layer is enabled
|
||||
if (!Theme::databaseLayerEnabled()) {
|
||||
return $this->error("cms.databaseTemplates is not enabled, enable it first and try again.");
|
||||
}
|
||||
|
||||
// Check to see if the provided theme exists
|
||||
$themeName = $this->argument('name') ?: Theme::getActiveThemeCode();
|
||||
$themeExists = Theme::exists($themeName);
|
||||
if (!$themeExists) {
|
||||
$themeName = strtolower(str_replace('.', '-', $themeName));
|
||||
$themeExists = Theme::exists($themeName);
|
||||
}
|
||||
if (!$themeExists) {
|
||||
return $this->error(sprintf('The theme %s does not exist.', $themeName));
|
||||
}
|
||||
$theme = Theme::load($themeName);
|
||||
$this->datasource = $theme->getDatasource();
|
||||
|
||||
// Get the target and source datasources
|
||||
$availableSources = ['filesystem', 'database'];
|
||||
$target = $this->option('target') ?: 'filesystem';
|
||||
$source = ($target === 'filesystem') ? 'database' : 'filesystem';
|
||||
|
||||
if (!in_array($target, $availableSources)) {
|
||||
return $this->error(sprintf("Provided --target of %s is invalid. Allowed: filesystem, database", $target));
|
||||
}
|
||||
|
||||
$this->source = $source;
|
||||
$this->target = $target;
|
||||
|
||||
// Get the theme paths, taking into account if the user has specified paths
|
||||
$userPaths = $this->option('paths') ?: null;
|
||||
$themePaths = array_keys($this->datasource->getSourcePaths($source));
|
||||
|
||||
if (!isset($userPaths)) {
|
||||
$paths = $themePaths;
|
||||
} else {
|
||||
$paths = [];
|
||||
$userPaths = array_map('trim', explode(',', $userPaths));
|
||||
|
||||
foreach ($userPaths as $userPath) {
|
||||
foreach ($themePaths as $themePath) {
|
||||
$pregMatch = '/^' . str_replace('/', '\/', $userPath) . '/i';
|
||||
|
||||
if ($userPath === $themePath || preg_match($pregMatch, $themePath)) {
|
||||
$paths[] = $themePath;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Determine valid paths based on the models made available for syncing
|
||||
$validPaths = [];
|
||||
|
||||
/**
|
||||
* @event system.console.theme.sync.getAvailableModelClasses
|
||||
* Defines the Halcyon models to be made available to the `theme:sync` tool.
|
||||
*
|
||||
* Example usage:
|
||||
*
|
||||
* Event::listen('system.console.theme.sync.getAvailableModelClasses', function () {
|
||||
* return [
|
||||
* Meta::class,
|
||||
* Page::class,
|
||||
* Layout::class,
|
||||
* Content::class,
|
||||
* Partial::class,
|
||||
* ];
|
||||
* });
|
||||
*
|
||||
*/
|
||||
$eventResults = Event::fire('system.console.theme.sync.getAvailableModelClasses');
|
||||
$validModels = [];
|
||||
|
||||
foreach ($eventResults as $result) {
|
||||
if (!is_array($result)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($result as $modelClass) {
|
||||
$modelObj = new $modelClass;
|
||||
|
||||
if ($modelObj instanceof \Winter\Storm\Halcyon\Model) {
|
||||
$validModels[] = $modelObj;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check each path and map it to a corresponding model
|
||||
foreach ($paths as $path) {
|
||||
foreach ($validModels as $model) {
|
||||
if (
|
||||
starts_with($path, $model->getObjectTypeDirName() . '/')
|
||||
&& in_array(pathinfo($path, PATHINFO_EXTENSION), $model->getAllowedExtensions())
|
||||
) {
|
||||
$validPaths[$path] = get_class($model);
|
||||
|
||||
// Skip to the next path
|
||||
continue 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (count($validPaths) === 0) {
|
||||
return $this->error(sprintf('No applicable paths found for %s.', $source));
|
||||
}
|
||||
|
||||
// Confirm with the user
|
||||
if (!$this->confirmToProceed(sprintf('This will OVERWRITE the %s provided paths in "themes/%s" on the %s with content from the %s', count($validPaths), $themeName, $target, $source), true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->info('Syncing files, please wait...');
|
||||
$progress = $this->output->createProgressBar(count($validPaths));
|
||||
|
||||
foreach ($validPaths as $path => $model) {
|
||||
$entity = $this->getModelForPath($path, $model, $theme);
|
||||
if (!isset($entity)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->datasource->pushToSource($entity, $target);
|
||||
$progress->advance();
|
||||
}
|
||||
|
||||
$progress->finish();
|
||||
$this->info('');
|
||||
$this->info(sprintf('The theme %s has been successfully synced from the %s to the %s.', $themeName, $source, $target));
|
||||
}
|
||||
catch (Exception $ex) {
|
||||
$this->error($ex->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get the correct Halcyon model for the provided path from the source datasource and load the requested path data.
|
||||
*
|
||||
* @param string $path
|
||||
* @param string $model
|
||||
* @param \Cms\Classes\Theme $theme
|
||||
* @return \Winter\Storm\Halcyon\Model
|
||||
*/
|
||||
protected function getModelForPath($path, $modelClass, $theme)
|
||||
{
|
||||
return $this->datasource->usingSource($this->source, function () use ($path, $modelClass, $theme) {
|
||||
$modelObj = new $modelClass;
|
||||
|
||||
$entity = $modelClass::load(
|
||||
$theme,
|
||||
str_replace($modelObj->getObjectTypeDirName() . '/', '', $path)
|
||||
);
|
||||
|
||||
if (!isset($entity)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $entity;
|
||||
});
|
||||
}
|
||||
}
|
||||
66
modules/cms/console/ThemeUse.php
Normal file
66
modules/cms/console/ThemeUse.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php namespace Cms\Console;
|
||||
|
||||
use Cms\Classes\Theme;
|
||||
use Winter\Storm\Console\Command;
|
||||
|
||||
/**
|
||||
* Console command to switch themes.
|
||||
*
|
||||
* This switches the active theme to another one, saved to the database.
|
||||
*
|
||||
* @package winter\wn-cms-module
|
||||
* @author Alexey Bobkov, Samuel Georges
|
||||
*/
|
||||
class ThemeUse extends Command
|
||||
{
|
||||
use \Illuminate\Console\ConfirmableTrait;
|
||||
|
||||
/**
|
||||
* The console command name.
|
||||
* @var string
|
||||
*/
|
||||
protected $name = 'theme:use';
|
||||
|
||||
/**
|
||||
* @var string The name and signature of this command.
|
||||
*/
|
||||
protected $signature = 'theme:use
|
||||
{name : The name of the theme. (directory name).}
|
||||
{--f|force : Force the operation to run.}
|
||||
';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Switch the active theme.';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
* @return void
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
if (!$this->confirmToProceed('Change the active theme?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$newThemeName = $this->argument('name');
|
||||
$newTheme = Theme::load($newThemeName);
|
||||
|
||||
if (!$newTheme->exists($newThemeName)) {
|
||||
return $this->error(sprintf('The theme %s does not exist.', $newThemeName));
|
||||
}
|
||||
|
||||
if ($newTheme->isActiveTheme()) {
|
||||
return $this->error(sprintf('%s is already the active theme.', $newTheme->getId()));
|
||||
}
|
||||
|
||||
$activeTheme = Theme::getActiveTheme();
|
||||
$from = $activeTheme ? $activeTheme->getId() : 'nothing';
|
||||
|
||||
$this->info(sprintf('Switching theme from %s to %s', $from, $newTheme->getId()));
|
||||
|
||||
Theme::setActiveTheme($newThemeName);
|
||||
}
|
||||
}
|
||||
25
modules/cms/console/scaffold/component/component.stub
Normal file
25
modules/cms/console/scaffold/component/component.stub
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php namespace {{studly_author}}\{{studly_plugin}}\Components;
|
||||
|
||||
use Cms\Classes\ComponentBase;
|
||||
|
||||
class {{studly_name}} extends ComponentBase
|
||||
{
|
||||
/**
|
||||
* Gets the details for the component
|
||||
*/
|
||||
public function componentDetails()
|
||||
{
|
||||
return [
|
||||
'name' => '{{name}} Component',
|
||||
'description' => 'No description provided yet...'
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the properties provided by the component
|
||||
*/
|
||||
public function defineProperties()
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
3
modules/cms/console/scaffold/component/default.stub
Normal file
3
modules/cms/console/scaffold/component/default.stub
Normal file
@@ -0,0 +1,3 @@
|
||||
<p>This is the default markup for component {{name}}</p>
|
||||
|
||||
<small>You can delete this file if you want</small>
|
||||
33
modules/cms/console/scaffold/theme/less/assets/js/app.stub
Normal file
33
modules/cms/console/scaffold/theme/less/assets/js/app.stub
Normal file
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Application
|
||||
*/
|
||||
(function($) {
|
||||
"use strict";
|
||||
|
||||
jQuery(document).ready(function($) {
|
||||
/*-------------------------------
|
||||
WINTER CMS FLASH MESSAGE HANDLING
|
||||
---------------------------------*/
|
||||
$(document).on('ajaxSetup', function(event, context) {
|
||||
// Enable AJAX handling of Flash messages on all AJAX requests
|
||||
context.options.flash = true;
|
||||
|
||||
// Enable the StripeLoadIndicator on all AJAX requests
|
||||
context.options.loading = $.oc.stripeLoadIndicator;
|
||||
|
||||
// Handle Flash Messages
|
||||
context.options.handleFlashMessage = function(message, type) {
|
||||
$.oc.flashMsg({ text: message, class: type });
|
||||
};
|
||||
|
||||
// Handle Error Messages
|
||||
context.options.handleErrorMessage = function(message) {
|
||||
$.oc.flashMsg({ text: message, class: 'error' });
|
||||
};
|
||||
});
|
||||
});
|
||||
}(jQuery));
|
||||
|
||||
if (typeof(gtag) !== 'function') {
|
||||
gtag = function() { console.log('GoogleAnalytics not present.'); }
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
.content {
|
||||
margin: 2em auto;
|
||||
max-width: 1080px;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
description = "Default layout"
|
||||
==
|
||||
{% partial "site/header" %}
|
||||
|
||||
{% page %}
|
||||
|
||||
{% partial "site/footer" %}
|
||||
8
modules/cms/console/scaffold/theme/less/pages/404.stub
Normal file
8
modules/cms/console/scaffold/theme/less/pages/404.stub
Normal file
@@ -0,0 +1,8 @@
|
||||
title = "Page not found (404)"
|
||||
url = "/404"
|
||||
layout = "default"
|
||||
==
|
||||
<div class="content">
|
||||
<h1>Page not found</h1>
|
||||
<p>We're sorry, but the page you requested cannot be found.</p>
|
||||
</div>
|
||||
8
modules/cms/console/scaffold/theme/less/pages/error.stub
Normal file
8
modules/cms/console/scaffold/theme/less/pages/error.stub
Normal file
@@ -0,0 +1,8 @@
|
||||
title = "Error page (500)"
|
||||
url = "/error"
|
||||
layout = "default"
|
||||
==
|
||||
<div class="content">
|
||||
<h1>Error</h1>
|
||||
<p>We're sorry, but something went wrong and the page cannot be displayed.</p>
|
||||
</div>
|
||||
7
modules/cms/console/scaffold/theme/less/pages/home.stub
Normal file
7
modules/cms/console/scaffold/theme/less/pages/home.stub
Normal file
@@ -0,0 +1,7 @@
|
||||
title = "Home"
|
||||
url = "/"
|
||||
layout = "default"
|
||||
==
|
||||
<div class="content">
|
||||
<h1>Home Page</h1>
|
||||
</div>
|
||||
@@ -0,0 +1,11 @@
|
||||
{% if this.theme.googleanalytics_id is not empty %}
|
||||
<!-- Global site tag (gtag.js) - Google Analytics -->
|
||||
<script async src="https://www.googletagmanager.com/gtag/js?id={{ this.theme.googleanalytics_id }}"></script>
|
||||
<script>
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag(){dataLayer.push(arguments);}
|
||||
gtag('js', new Date());
|
||||
|
||||
gtag('config', '{{ this.theme.googleanalytics_id }}');
|
||||
</script>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,4 @@
|
||||
<link rel="stylesheet" href="{{ ['assets/less/theme.less'] | theme }}">
|
||||
|
||||
{% styles %}
|
||||
{% placeholder head %}
|
||||
@@ -0,0 +1,17 @@
|
||||
<!-- Scripts -->
|
||||
<script src="{{ [
|
||||
'@jquery',
|
||||
'@framework',
|
||||
'@framework.extras',
|
||||
|
||||
'assets/js/app.js',
|
||||
] | theme }}"></script>
|
||||
{% scripts %}
|
||||
|
||||
{% flash %}
|
||||
<p data-control="flash-message" data-interval="7" class="flashmessage {{ type }}">
|
||||
{{ message }}
|
||||
</p>
|
||||
{% endflash %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,20 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% placeholder page_title default %}{{ this.page.title }}{% endplaceholder %}</title>
|
||||
{% partial "meta/styles" %}
|
||||
{% partial "meta/seo" %}
|
||||
<meta name="generator" content="Winter CMS">
|
||||
</head>
|
||||
{% set pageId = this.page.id %}
|
||||
{% set pageTitle = this.page.title %}
|
||||
{% if pageId is empty %}
|
||||
{% set pageId = page.id %}
|
||||
{% endif %}
|
||||
{% if pageTitle is empty %}
|
||||
{% set pageTitle = page.title %}
|
||||
{% endif %}
|
||||
<body class="page-{{ pageId }} layout-{{ this.layout.id }}">
|
||||
9
modules/cms/console/scaffold/theme/less/theme.stub
Normal file
9
modules/cms/console/scaffold/theme/less/theme.stub
Normal file
@@ -0,0 +1,9 @@
|
||||
name: "{{code}}"
|
||||
description: "No description provided yet..."
|
||||
author: "Winter CMS Scaffold"
|
||||
homepage: "https://example.com"
|
||||
code: "{{code}}"
|
||||
form:
|
||||
fields:
|
||||
googleanalytics_id:
|
||||
label: 'Google Analytics ID'
|
||||
1
modules/cms/console/scaffold/theme/less/version.stub
Normal file
1
modules/cms/console/scaffold/theme/less/version.stub
Normal file
@@ -0,0 +1 @@
|
||||
1.0.1: 'Initial version'
|
||||
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
dist/
|
||||
9
modules/cms/console/scaffold/theme/tailwind/README.stub
Normal file
9
modules/cms/console/scaffold/theme/tailwind/README.stub
Normal file
@@ -0,0 +1,9 @@
|
||||
# {{code}} Winter CMS Theme
|
||||
|
||||
This theme uses [Vite](https://wintercms.com/docs/develop/docs/console/asset-compilation-vite) for asset compilation. It also uses [Tailwind CSS](https://tailwindcss.com/).
|
||||
|
||||
## Getting Started
|
||||
|
||||
1. Run [`artisan vite:install`](https://wintercms.com/docs/console/asset-compilation#mix-install) and agree when asked to modify the `package.json` file for your project in order to register & install this theme's dependencies.
|
||||
2. Run [`artisan vite:compile -p theme-{{code}} --production`](https://wintercms.com/docs/develop/docs/console/asset-compilation-vite#compile-a-vite-packages) to compile the asset files for this theme.
|
||||
3. Optionally, run [`artisan vite:watch theme-{{code}}`](https://wintercms.com/docs/develop/docs/console/asset-compilation-vite#watch-a-vite-package) while actively working on the theme to have the assets automatically recompiled in the background for you every time you make a change.
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'options' => [
|
||||
'googleanalytics_id' => 'Google Analytics ID',
|
||||
'color_primary' => 'Primary Color',
|
||||
'color_secondary' => 'secondary Color',
|
||||
],
|
||||
];
|
||||
@@ -0,0 +1,8 @@
|
||||
description = "Default layout"
|
||||
default = true
|
||||
==
|
||||
{% partial "site/header" %}
|
||||
|
||||
{% page %}
|
||||
|
||||
{% partial "site/footer" %}
|
||||
@@ -0,0 +1,8 @@
|
||||
title = "Page not found (404)"
|
||||
url = "/404"
|
||||
layout = "default"
|
||||
==
|
||||
<div>
|
||||
<h1>Page not found</h1>
|
||||
<p>We're sorry, but the page you requested cannot be found.</p>
|
||||
</div>
|
||||
@@ -0,0 +1,8 @@
|
||||
title = "Error page (500)"
|
||||
url = "/error"
|
||||
layout = "default"
|
||||
==
|
||||
<div>
|
||||
<h1>Error</h1>
|
||||
<p>We're sorry, but something went wrong and the page cannot be displayed.</p>
|
||||
</div>
|
||||
@@ -0,0 +1,7 @@
|
||||
title = "Home"
|
||||
url = "/"
|
||||
layout = "default"
|
||||
==
|
||||
<div class="container mx-auto">
|
||||
<h1><span class="text-primary">Home</span> <span class="text-secondary">Page</span></h1>
|
||||
</div>
|
||||
@@ -0,0 +1,11 @@
|
||||
{% if this.theme.googleanalytics_id is not empty %}
|
||||
<!-- Global site tag (gtag.js) - Google Analytics -->
|
||||
<script async src="https://www.googletagmanager.com/gtag/js?id={{ this.theme.googleanalytics_id }}"></script>
|
||||
<script>
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag(){dataLayer.push(arguments);}
|
||||
gtag('js', new Date());
|
||||
|
||||
gtag('config', '{{ this.theme.googleanalytics_id }}');
|
||||
</script>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,12 @@
|
||||
==
|
||||
{{ vite(['assets/src/css/theme-{{code}}.css'], 'theme-{{code}}') }}
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--primary: {{ this.theme.color_primary }};
|
||||
--secondary: {{ this.theme.color_secondary }};
|
||||
}
|
||||
</style>
|
||||
|
||||
{% styles %}
|
||||
{% placeholder head %}
|
||||
@@ -0,0 +1,21 @@
|
||||
<!-- Scripts -->
|
||||
|
||||
{# jQuery AJAX Framework #}
|
||||
<script src="{{ [
|
||||
'@jquery',
|
||||
'@framework',
|
||||
'@framework.extras',
|
||||
] | theme }}"></script>
|
||||
|
||||
{# Vite extracted assets #}
|
||||
{{ vite(['assets/src/js/theme-{{code}}.js'], 'theme-{{code}}') }}
|
||||
|
||||
{% scripts %}
|
||||
|
||||
{% flash %}
|
||||
<p data-control="flash-message" data-interval="7" class="flashmessage {{ type }}">
|
||||
{{ message }}
|
||||
</p>
|
||||
{% endflash %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,20 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% placeholder page_title default %}{{ this.page.title }}{% endplaceholder %}</title>
|
||||
{% partial "meta/styles" %}
|
||||
{% partial "meta/seo" %}
|
||||
<meta name="generator" content="Winter CMS">
|
||||
</head>
|
||||
{% set pageId = this.page.id %}
|
||||
{% set pageTitle = this.page.title %}
|
||||
{% if pageId is empty %}
|
||||
{% set pageId = page.id %}
|
||||
{% endif %}
|
||||
{% if pageTitle is empty %}
|
||||
{% set pageTitle = page.title %}
|
||||
{% endif %}
|
||||
<body class="page-{{ pageId }} layout-{{ this.layout.id }}">
|
||||
21
modules/cms/console/scaffold/theme/tailwind/theme.stub
Normal file
21
modules/cms/console/scaffold/theme/tailwind/theme.stub
Normal file
@@ -0,0 +1,21 @@
|
||||
name: "{{code}}"
|
||||
description: "No description provided yet..."
|
||||
author: "Winter CMS Scaffold"
|
||||
homepage: "https://example.com"
|
||||
code: "{{code}}"
|
||||
form:
|
||||
fields:
|
||||
googleanalytics_id:
|
||||
label: themes.{{code}}::lang.options.googleanalytics_id
|
||||
type: text
|
||||
span: full
|
||||
color_primary:
|
||||
label: themes.{{code}}::lang.options.color_primary
|
||||
type: colorpicker
|
||||
span: left
|
||||
default: "#103141"
|
||||
color_secondary:
|
||||
label: themes.{{code}}::lang.options.color_secondary
|
||||
type: colorpicker
|
||||
span: right
|
||||
default: "#2DA7C7"
|
||||
1
modules/cms/console/scaffold/theme/tailwind/version.stub
Normal file
1
modules/cms/console/scaffold/theme/tailwind/version.stub
Normal file
@@ -0,0 +1 @@
|
||||
1.0.0: 'Initial version'
|
||||
61
modules/cms/contracts/CmsObject.php
Normal file
61
modules/cms/contracts/CmsObject.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php namespace Cms\Contracts;
|
||||
|
||||
interface CmsObject
|
||||
{
|
||||
/**
|
||||
* Loads the template.
|
||||
*
|
||||
* @param string $hostObj
|
||||
* @param string $fileName
|
||||
* @return mixed
|
||||
*/
|
||||
public static function load($hostObj, $fileName);
|
||||
|
||||
/**
|
||||
* Loads and caches the template.
|
||||
*
|
||||
* @param string $hostObj
|
||||
* @param string $fileName
|
||||
* @return mixed
|
||||
*/
|
||||
public static function loadCached($hostObj, $fileName);
|
||||
|
||||
/**
|
||||
* Returns the local file path to the template.
|
||||
*
|
||||
* @param string $fileName
|
||||
* @return string
|
||||
*/
|
||||
public function getFilePath($fileName = null);
|
||||
|
||||
/**
|
||||
* Returns the file name.
|
||||
* @return string
|
||||
*/
|
||||
public function getFileName();
|
||||
|
||||
/**
|
||||
* Returns the file name without the extension.
|
||||
* @return string
|
||||
*/
|
||||
public function getBaseFileName();
|
||||
|
||||
/**
|
||||
* Returns the file content.
|
||||
* @return string
|
||||
*/
|
||||
public function getContent();
|
||||
|
||||
/**
|
||||
* Returns the Twig content string.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getTwigContent();
|
||||
|
||||
/**
|
||||
* Returns the key used by the Twig cache.
|
||||
* @return string
|
||||
*/
|
||||
public function getTwigCacheKey();
|
||||
}
|
||||
922
modules/cms/controllers/Index.php
Normal file
922
modules/cms/controllers/Index.php
Normal file
@@ -0,0 +1,922 @@
|
||||
<?php
|
||||
|
||||
namespace Cms\Controllers;
|
||||
|
||||
use Backend\Classes\Controller;
|
||||
use Backend\Facades\BackendMenu;
|
||||
use Backend\Widgets\Form;
|
||||
use Cms\Classes\Asset;
|
||||
use Cms\Classes\CmsCompoundObject;
|
||||
use Cms\Classes\CmsObject;
|
||||
use Cms\Classes\ComponentManager;
|
||||
use Cms\Classes\ComponentPartial;
|
||||
use Cms\Classes\Content;
|
||||
use Cms\Classes\Layout;
|
||||
use Cms\Classes\Page;
|
||||
use Cms\Classes\Partial;
|
||||
use Cms\Classes\Router;
|
||||
use Cms\Classes\Theme;
|
||||
use Cms\Helpers\Cms as CmsHelpers;
|
||||
use Cms\Widgets\AssetList;
|
||||
use Cms\Widgets\ComponentList;
|
||||
use Cms\Widgets\TemplateList;
|
||||
use Exception;
|
||||
use Illuminate\Support\Facades\Lang;
|
||||
use Illuminate\Support\Facades\Request;
|
||||
use System\Helpers\DateTime;
|
||||
use Winter\Storm\Auth\AuthorizationException;
|
||||
use Winter\Storm\Exception\ApplicationException;
|
||||
use Winter\Storm\Halcyon\Datasource\DatasourceInterface;
|
||||
use Winter\Storm\Router\Router as StormRouter;
|
||||
use Winter\Storm\Support\Facades\Config;
|
||||
use Winter\Storm\Support\Facades\Event;
|
||||
use Winter\Storm\Support\Facades\Flash;
|
||||
use Winter\Storm\Support\Facades\Url;
|
||||
|
||||
/**
|
||||
* CMS index
|
||||
*
|
||||
* @package winter\wn-cms-module
|
||||
* @author Alexey Bobkov, Samuel Georges
|
||||
*/
|
||||
class Index extends Controller
|
||||
{
|
||||
use \Backend\Traits\InspectableContainer;
|
||||
|
||||
/**
|
||||
* @var Theme
|
||||
*/
|
||||
protected $theme;
|
||||
|
||||
/**
|
||||
* @var array Permissions required to view this page.
|
||||
*/
|
||||
public $requiredPermissions = [
|
||||
'cms.manage_content',
|
||||
'cms.manage_assets',
|
||||
'cms.manage_pages',
|
||||
'cms.manage_layouts',
|
||||
'cms.manage_partials'
|
||||
];
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
|
||||
Event::listen('backend.form.extendFieldsBefore', function ($widget) {
|
||||
if (!$widget->getController() instanceof Index) {
|
||||
return;
|
||||
}
|
||||
if (!$widget->model instanceof CmsCompoundObject) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (empty($widget->secondaryTabs['fields'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (array_key_exists('code', $widget->secondaryTabs['fields']) && CmsHelpers::safeModeEnabled()) {
|
||||
$widget->secondaryTabs['fields']['safemode_notice']['hidden'] = false;
|
||||
$widget->secondaryTabs['fields']['code']['readOnly'] = true;
|
||||
};
|
||||
});
|
||||
|
||||
BackendMenu::setContext('Winter.Cms', 'cms', true);
|
||||
|
||||
try {
|
||||
if (!($theme = Theme::getEditTheme())) {
|
||||
throw new ApplicationException(Lang::get('cms::lang.theme.edit.not_found'));
|
||||
}
|
||||
|
||||
$this->theme = $theme;
|
||||
|
||||
if ($this->user?->hasAccess('cms.manage_pages')) {
|
||||
new TemplateList($this, 'pageList', function () use ($theme) {
|
||||
return Page::listInTheme($theme, true);
|
||||
});
|
||||
}
|
||||
|
||||
if ($this->user?->hasAccess('cms.manage_partials')) {
|
||||
new TemplateList($this, 'partialList', function () use ($theme) {
|
||||
return Partial::listInTheme($theme, true);
|
||||
});
|
||||
}
|
||||
|
||||
if ($this->user?->hasAccess('cms.manage_layouts')) {
|
||||
new TemplateList($this, 'layoutList', function () use ($theme) {
|
||||
return Layout::listInTheme($theme, true);
|
||||
});
|
||||
}
|
||||
|
||||
if ($this->user?->hasAccess('cms.manage_content')) {
|
||||
new TemplateList($this, 'contentList', function () use ($theme) {
|
||||
return Content::listInTheme($theme, true);
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
$this->user?->hasAccess([
|
||||
'cms.manage_pages',
|
||||
'cms.manage_partials',
|
||||
'cms.manage_layouts',
|
||||
], false)
|
||||
) {
|
||||
new ComponentList($this, 'componentList');
|
||||
}
|
||||
|
||||
if ($this->user?->hasAccess('cms.manage_assets')) {
|
||||
new AssetList($this, 'assetList');
|
||||
}
|
||||
}
|
||||
catch (Exception $ex) {
|
||||
$this->handleError($ex);
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Pages
|
||||
//
|
||||
|
||||
/**
|
||||
* Index page action
|
||||
*/
|
||||
public function index(): void
|
||||
{
|
||||
$this->addJs('/modules/cms/assets/js/winter.cmspage.js', 'core');
|
||||
$this->addJs('/modules/cms/assets/js/winter.dragcomponents.js', 'core');
|
||||
$this->addJs('/modules/cms/assets/js/winter.tokenexpander.js', 'core');
|
||||
$this->addCss('/modules/cms/assets/css/winter.components.css', 'core');
|
||||
|
||||
$this->bodyClass = 'compact-container';
|
||||
$this->pageTitle = 'cms::lang.cms.menu_label';
|
||||
$this->pageTitleTemplate = '%s '.Lang::get($this->pageTitle);
|
||||
|
||||
if (Request::ajax() && Request::input('formWidgetAlias')) {
|
||||
$this->bindFormWidgetToController();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens an existing template from the index page
|
||||
*/
|
||||
public function index_onOpenTemplate(): array
|
||||
{
|
||||
$this->validateRequestTheme();
|
||||
|
||||
$type = Request::input('type');
|
||||
$this->validateRequestType($type);
|
||||
|
||||
$template = $this->loadTemplate($type, Request::input('path'));
|
||||
$widget = $this->makeTemplateFormWidget($type, $template);
|
||||
|
||||
$this->vars['templatePath'] = Request::input('path');
|
||||
$this->vars['lastModified'] = DateTime::makeCarbon($template->mtime);
|
||||
$this->vars['canCommit'] = $this->canCommitTemplate($template);
|
||||
$this->vars['canReset'] = $this->canResetTemplate($template);
|
||||
|
||||
if ($type === 'page') {
|
||||
$router = new StormRouter;
|
||||
$this->vars['pageUrl'] = $router->urlFromPattern($template->url);
|
||||
}
|
||||
|
||||
return [
|
||||
'tabTitle' => $this->getTabTitle($type, $template),
|
||||
'tab' => $this->makePartial('form_page', [
|
||||
'form' => $widget,
|
||||
'templateType' => $type,
|
||||
'templateTheme' => $this->theme->getDirName(),
|
||||
'templateMtime' => $template->mtime
|
||||
])
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the template currently open
|
||||
* @throws ApplicationException if the file in the datasource has been modified since the file was loaded in the browser
|
||||
*/
|
||||
public function onSave(): array
|
||||
{
|
||||
$this->validateRequestTheme();
|
||||
|
||||
$type = Request::input('templateType');
|
||||
$this->validateRequestType($type);
|
||||
|
||||
$templatePath = trim(Request::input('templatePath'));
|
||||
$template = $templatePath ? $this->loadTemplate($type, $templatePath) : $this->createTemplate($type);
|
||||
$formWidget = $this->makeTemplateFormWidget($type, $template);
|
||||
|
||||
$saveData = $formWidget->getSaveData();
|
||||
$postData = post();
|
||||
$templateData = [];
|
||||
|
||||
$settings = array_get($saveData, 'settings', []) + Request::input('settings', []);
|
||||
$settings = $this->upgradeSettings($settings, $template->settings);
|
||||
|
||||
if ($settings) {
|
||||
$templateData['settings'] = $settings;
|
||||
}
|
||||
|
||||
$fields = ['markup', 'code', 'fileName', 'content'];
|
||||
|
||||
foreach ($fields as $field) {
|
||||
if (array_key_exists($field, $saveData)) {
|
||||
$templateData[$field] = $saveData[$field];
|
||||
}
|
||||
elseif (array_key_exists($field, $postData)) {
|
||||
$templateData[$field] = $postData[$field];
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($templateData['markup']) && Config::get('cms.convertLineEndings', false) === true) {
|
||||
$templateData['markup'] = $this->convertLineEndings($templateData['markup']);
|
||||
}
|
||||
|
||||
if (!empty($templateData['code']) && Config::get('cms.convertLineEndings', false) === true) {
|
||||
$templateData['code'] = $this->convertLineEndings($templateData['code']);
|
||||
}
|
||||
|
||||
if (
|
||||
!Request::input('templateForceSave') && $template->mtime
|
||||
&& Request::input('templateMtime') != $template->mtime
|
||||
) {
|
||||
throw new ApplicationException('mtime-mismatch');
|
||||
}
|
||||
|
||||
$template->attributes = [];
|
||||
$template->fill($templateData);
|
||||
$template->save();
|
||||
|
||||
/**
|
||||
* @event cms.template.save
|
||||
* Fires after a CMS template (page|partial|layout|content|asset) has been saved.
|
||||
*
|
||||
* Example usage:
|
||||
*
|
||||
* Event::listen('cms.template.save', function ((\Cms\Controllers\Index) $controller, (mixed) $templateObject, (string) $type) {
|
||||
* \Log::info("A $type has been saved");
|
||||
* });
|
||||
*
|
||||
* Or
|
||||
*
|
||||
* $CmsIndexController->bindEvent('template.save', function ((mixed) $templateObject, (string) $type) {
|
||||
* \Log::info("A $type has been saved");
|
||||
* });
|
||||
*
|
||||
*/
|
||||
$this->fireSystemEvent('cms.template.save', [$template, $type]);
|
||||
|
||||
Flash::success(Lang::get('cms::lang.template.saved'));
|
||||
|
||||
return $this->getUpdateResponse($template, $type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays a form that suggests the template has been edited elsewhere
|
||||
*/
|
||||
public function onOpenConcurrencyResolveForm(): string
|
||||
{
|
||||
return $this->makePartial('concurrency_resolve_form');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new template
|
||||
*/
|
||||
public function onCreateTemplate(): array
|
||||
{
|
||||
$type = Request::input('type');
|
||||
$this->validateRequestType($type);
|
||||
|
||||
$template = $this->createTemplate($type);
|
||||
|
||||
if ($type === 'asset') {
|
||||
$template->fileName = $this->widget->assetList->getCurrentRelativePath();
|
||||
}
|
||||
|
||||
$widget = $this->makeTemplateFormWidget($type, $template);
|
||||
|
||||
$this->vars['templatePath'] = '';
|
||||
$this->vars['canCommit'] = $this->canCommitTemplate($template);
|
||||
$this->vars['canReset'] = $this->canResetTemplate($template);
|
||||
|
||||
return [
|
||||
'tabTitle' => $this->getTabTitle($type, $template),
|
||||
'tab' => $this->makePartial('form_page', [
|
||||
'form' => $widget,
|
||||
'templateType' => $type,
|
||||
'templateTheme' => $this->theme->getDirName(),
|
||||
'templateMtime' => null
|
||||
])
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes multiple templates at the same time
|
||||
*/
|
||||
public function onDeleteTemplates(): array
|
||||
{
|
||||
$this->validateRequestTheme();
|
||||
|
||||
$type = Request::input('type');
|
||||
$this->validateRequestType($type);
|
||||
|
||||
$templates = Request::input('template');
|
||||
$error = null;
|
||||
$deleted = [];
|
||||
|
||||
try {
|
||||
foreach ($templates as $path => $selected) {
|
||||
if ($selected) {
|
||||
$this->loadTemplate($type, $path)->delete();
|
||||
$deleted[] = $path;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception $ex) {
|
||||
$error = $ex->getMessage();
|
||||
}
|
||||
|
||||
/**
|
||||
* @event cms.template.delete
|
||||
* Fires after a CMS template (page|partial|layout|content|asset) has been deleted.
|
||||
*
|
||||
* Example usage:
|
||||
*
|
||||
* Event::listen('cms.template.delete', function ((\Cms\Controllers\Index) $controller, (string) $type) {
|
||||
* \Log::info("A $type has been deleted");
|
||||
* });
|
||||
*
|
||||
* Or
|
||||
*
|
||||
* $CmsIndexController->bindEvent('template.delete', function ((string) $type) {
|
||||
* \Log::info("A $type has been deleted");
|
||||
* });
|
||||
*
|
||||
*/
|
||||
$this->fireSystemEvent('cms.template.delete', [$type]);
|
||||
|
||||
return [
|
||||
'deleted' => $deleted,
|
||||
'error' => $error,
|
||||
'theme' => Request::input('theme')
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a template
|
||||
*/
|
||||
public function onDelete(): void
|
||||
{
|
||||
$this->validateRequestTheme();
|
||||
|
||||
$type = Request::input('templateType');
|
||||
$this->validateRequestType($type);
|
||||
|
||||
$this->loadTemplate($type, trim(Request::input('templatePath')))->delete();
|
||||
|
||||
/*
|
||||
* Extensibility - documented above
|
||||
*/
|
||||
$this->fireSystemEvent('cms.template.delete', [$type]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns list of available templates
|
||||
*/
|
||||
public function onGetTemplateList(): array
|
||||
{
|
||||
$this->validateRequestTheme();
|
||||
|
||||
$page = Page::inTheme($this->theme);
|
||||
return [
|
||||
'layouts' => $page->getLayoutOptions()
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Remembers an open or closed state for a supplied token, for example, component folders.
|
||||
*/
|
||||
public function onExpandMarkupToken(): string
|
||||
{
|
||||
if (!$alias = post('tokenName')) {
|
||||
throw new ApplicationException(Lang::get('cms::lang.component.no_records'));
|
||||
}
|
||||
|
||||
// Can only expand components at this stage
|
||||
if ((!$type = post('tokenType')) && $type !== 'component') {
|
||||
throw new ApplicationException("Unsupported token type: $type");
|
||||
}
|
||||
|
||||
if (!($names = (array) post('component_names')) || !($aliases = (array) post('component_aliases'))) {
|
||||
throw new ApplicationException(Lang::get('cms::lang.component.not_found', ['name' => $alias]));
|
||||
}
|
||||
|
||||
if (($index = array_get(array_flip($aliases), $alias, false)) === false) {
|
||||
throw new ApplicationException(Lang::get('cms::lang.component.not_found', ['name' => $alias]));
|
||||
}
|
||||
|
||||
if (!$componentName = array_get($names, $index)) {
|
||||
throw new ApplicationException(Lang::get('cms::lang.component.not_found', ['name' => $alias]));
|
||||
}
|
||||
|
||||
$manager = ComponentManager::instance();
|
||||
$componentObj = $manager->makeComponent($componentName);
|
||||
/**
|
||||
* @var ?ComponentPartial
|
||||
*/
|
||||
$partial = ComponentPartial::load($componentObj, 'default');
|
||||
|
||||
if (!$partial) {
|
||||
throw new ApplicationException(Lang::get('cms::lang.component.no_default_partial'));
|
||||
}
|
||||
|
||||
$content = $partial->getContent();
|
||||
$content = str_replace('__SELF__', $alias, $content);
|
||||
|
||||
return $content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Commits the DB changes of a template to the filesystem
|
||||
*/
|
||||
public function onCommit(): array
|
||||
{
|
||||
$this->validateRequestTheme();
|
||||
|
||||
$type = Request::input('templateType');
|
||||
$this->validateRequestType($type);
|
||||
|
||||
$template = $this->loadTemplate($type, trim(Request::input('templatePath')));
|
||||
|
||||
if ($this->canCommitTemplate($template)) {
|
||||
// Populate the filesystem with the template and then remove it from the db
|
||||
/**
|
||||
* @var AutoDatasource
|
||||
*/
|
||||
$datasource = $this->getThemeDatasource();
|
||||
$datasource->pushToSource($template, 'filesystem');
|
||||
$datasource->removeFromSource($template, 'database');
|
||||
|
||||
Flash::success(Lang::get('cms::lang.editor.commit_success', ['type' => $type]));
|
||||
}
|
||||
|
||||
return array_merge($this->getUpdateResponse($template, $type), ['forceReload' => true]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets a template to the version on the filesystem
|
||||
*/
|
||||
public function onReset(): array
|
||||
{
|
||||
$this->validateRequestTheme();
|
||||
|
||||
$type = Request::input('templateType');
|
||||
$this->validateRequestType($type);
|
||||
|
||||
$template = $this->loadTemplate($type, trim(Request::input('templatePath')));
|
||||
|
||||
if ($this->canResetTemplate($template)) {
|
||||
// Remove the template from the DB
|
||||
/**
|
||||
* @var AutoDatasource
|
||||
*/
|
||||
$datasource = $this->getThemeDatasource();
|
||||
$datasource->removeFromSource($template, 'database');
|
||||
|
||||
Flash::success(Lang::get('cms::lang.editor.reset_success', ['type' => $type]));
|
||||
}
|
||||
|
||||
return array_merge($this->getUpdateResponse($template, $type), ['forceReload' => true]);
|
||||
}
|
||||
|
||||
//
|
||||
// Methods for internal use
|
||||
//
|
||||
|
||||
/**
|
||||
* Get the response to return in an AJAX request that updates a template
|
||||
*
|
||||
* @param object $template The template that has been affected
|
||||
* @param string $type The type of template being affected
|
||||
*/
|
||||
protected function getUpdateResponse($template, string $type): array
|
||||
{
|
||||
$result = [
|
||||
'templatePath' => $template->fileName,
|
||||
'templateMtime' => $template->mtime,
|
||||
'tabTitle' => $this->getTabTitle($type, $template)
|
||||
];
|
||||
|
||||
if ($type === 'page') {
|
||||
$result['pageUrl'] = Url::to($template->url);
|
||||
$router = new Router($this->theme);
|
||||
$router->clearCache();
|
||||
CmsCompoundObject::clearCache($this->theme);
|
||||
}
|
||||
|
||||
$result['canCommit'] = $this->canCommitTemplate($template);
|
||||
$result['canReset'] = $this->canResetTemplate($template);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the active theme's datasource
|
||||
*/
|
||||
protected function getThemeDatasource(): DatasourceInterface
|
||||
{
|
||||
return $this->theme->getDatasource();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check to see if the provided template can be committed
|
||||
* Only available in debug mode, the DB layer must be enabled, and the template must exist in the database
|
||||
*/
|
||||
protected function canCommitTemplate(CmsObject|Asset $template): bool
|
||||
{
|
||||
if (
|
||||
$template instanceof CmsObject === false
|
||||
|| Config::get('app.debug', false)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$result = false;
|
||||
|
||||
if (Theme::databaseLayerEnabled()) {
|
||||
/**
|
||||
* @var AutoDatasource
|
||||
*/
|
||||
$datasource = $this->getThemeDatasource();
|
||||
$result = $datasource->sourceHasModel('database', $template);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check to see if the provided template can be reset
|
||||
* Only available when the DB layer is enabled and the template exists in both the DB & Filesystem
|
||||
*/
|
||||
protected function canResetTemplate(CmsObject|Asset $template): bool
|
||||
{
|
||||
if ($template instanceof CmsObject === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$result = false;
|
||||
|
||||
if (Theme::databaseLayerEnabled()) {
|
||||
/**
|
||||
* @var AutoDatasource
|
||||
*/
|
||||
$datasource = $this->getThemeDatasource();
|
||||
$result = $datasource->sourceHasModel('database', $template) && $datasource->sourceHasModel('filesystem', $template);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that the current request is within the active theme
|
||||
* @throws ApplicationException if the requested theme does not match the currently loaded theme
|
||||
*/
|
||||
protected function validateRequestTheme(): void
|
||||
{
|
||||
if ($this->theme->getDirName() != Request::input('theme')) {
|
||||
throw new ApplicationException(Lang::get('cms::lang.theme.edit.not_match'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that the given request type is a valid type, and that the user has the relevant
|
||||
* permission to access it.
|
||||
*
|
||||
* @throws AuthorizationException if the user doesn't have permission to access the type
|
||||
*/
|
||||
protected function validateRequestType(string $type): void
|
||||
{
|
||||
$this->resolveTypeClassName($type);
|
||||
|
||||
if (!$this->user?->hasAccess($this->getRelevantPermissionForType($type))) {
|
||||
throw new AuthorizationException(Lang::get('cms::lang.template.type_not_permitted', [
|
||||
'type' => str_plural($type),
|
||||
'permission' => $this->getRelevantPermissionForType($type),
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the relevant permission required for a specific template type.
|
||||
*/
|
||||
protected function getRelevantPermissionForType(string $type): string
|
||||
{
|
||||
return match ($type) {
|
||||
'page' => 'cms.manage_pages',
|
||||
'partial' => 'cms.manage_partials',
|
||||
'layout' => 'cms.manage_layouts',
|
||||
'content' => 'cms.manage_content',
|
||||
'asset' => 'cms.manage_assets',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a template type to its class name
|
||||
*/
|
||||
protected function resolveTypeClassName(string $type): string
|
||||
{
|
||||
$types = [
|
||||
'page' => Page::class,
|
||||
'partial' => Partial::class,
|
||||
'layout' => Layout::class,
|
||||
'content' => Content::class,
|
||||
'asset' => Asset::class
|
||||
];
|
||||
|
||||
if (!array_key_exists($type, $types)) {
|
||||
throw new ApplicationException(Lang::get('cms::lang.template.invalid_type'));
|
||||
}
|
||||
|
||||
return $types[$type];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an existing template of a given type
|
||||
*/
|
||||
protected function loadTemplate(string $type, string $path): CmsObject|Asset
|
||||
{
|
||||
$class = $this->resolveTypeClassName($type);
|
||||
|
||||
if (!($template = call_user_func([$class, 'load'], $this->theme, $path))) {
|
||||
throw new ApplicationException(Lang::get('cms::lang.template.not_found'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @event cms.template.processSettingsAfterLoad
|
||||
* Fires immediately after a CMS template (page|partial|layout|content|asset) has been loaded and provides an opportunity to interact with it.
|
||||
*
|
||||
* Example usage:
|
||||
*
|
||||
* Event::listen('cms.template.processSettingsAfterLoad', function ((\Cms\Controllers\Index) $controller, (mixed) $templateObject) {
|
||||
* // Make some modifications to the $template object
|
||||
* });
|
||||
*
|
||||
* Or
|
||||
*
|
||||
* $CmsIndexController->bindEvent('template.processSettingsAfterLoad', function ((mixed) $templateObject) {
|
||||
* // Make some modifications to the $template object
|
||||
* });
|
||||
*
|
||||
*/
|
||||
$this->fireSystemEvent('cms.template.processSettingsAfterLoad', [$template]);
|
||||
|
||||
return $template;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new template of a given type
|
||||
* @throws ApplicationException if the requested type can't be initialized with the current theme
|
||||
*/
|
||||
protected function createTemplate(string $type): CmsObject|Asset
|
||||
{
|
||||
$class = $this->resolveTypeClassName($type);
|
||||
|
||||
if (!($template = $class::inTheme($this->theme))) {
|
||||
throw new ApplicationException(Lang::get('cms::lang.template.not_found'));
|
||||
}
|
||||
|
||||
return $template;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the text for a template tab
|
||||
*/
|
||||
protected function getTabTitle(string $type, CmsObject|Asset $template): string
|
||||
{
|
||||
if ($type === 'page') {
|
||||
$result = $template->title ?: $template->getFileName();
|
||||
if (!$result) {
|
||||
$result = Lang::get('cms::lang.page.new');
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
if ($type === 'partial' || $type === 'layout' || $type === 'content' || $type === 'asset') {
|
||||
$result = in_array($type, ['asset', 'content']) ? $template->getFileName() : $template->getBaseFileName();
|
||||
if (!$result) {
|
||||
$result = Lang::get('cms::lang.'.$type.'.new');
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
return $template->getFileName();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a form widget for a specified template type.
|
||||
*/
|
||||
protected function makeTemplateFormWidget(string $type, CmsObject|Asset $template, ?string $alias = null): Form
|
||||
{
|
||||
$this->validateRequestType($type);
|
||||
|
||||
$formConfigs = [
|
||||
'page' => '~/modules/cms/classes/page/fields.yaml',
|
||||
'partial' => '~/modules/cms/classes/partial/fields.yaml',
|
||||
'layout' => '~/modules/cms/classes/layout/fields.yaml',
|
||||
'content' => '~/modules/cms/classes/content/fields.yaml',
|
||||
'asset' => '~/modules/cms/classes/asset/fields.yaml'
|
||||
];
|
||||
|
||||
if (!array_key_exists($type, $formConfigs)) {
|
||||
throw new ApplicationException(Lang::get('cms::lang.template.not_found'));
|
||||
}
|
||||
|
||||
$widgetConfig = $this->makeConfig($formConfigs[$type]);
|
||||
|
||||
$ext = pathinfo($template->fileName, PATHINFO_EXTENSION);
|
||||
if ($type === 'content') {
|
||||
switch ($ext) {
|
||||
case 'htm':
|
||||
$type = 'richeditor';
|
||||
break;
|
||||
case 'md':
|
||||
$type = 'markdown';
|
||||
break;
|
||||
default:
|
||||
$type = 'codeeditor';
|
||||
break;
|
||||
}
|
||||
array_set($widgetConfig->secondaryTabs, 'fields.markup.type', $type);
|
||||
}
|
||||
|
||||
$codeField = ($template instanceof Asset) ? 'content' : 'markup';
|
||||
|
||||
$lang = match ($ext) {
|
||||
'', 'htm' => 'twig',
|
||||
'html' => 'html',
|
||||
'css' => 'css',
|
||||
'js', 'json' => 'javascript',
|
||||
'less' => 'less',
|
||||
'sass', 'scss' => 'scss',
|
||||
'txt' => 'txt',
|
||||
default => 'php',
|
||||
};
|
||||
|
||||
if (array_get($widgetConfig->secondaryTabs, "fields.$codeField.type") === 'codeeditor') {
|
||||
array_set($widgetConfig->secondaryTabs, "fields.$codeField.language", $lang);
|
||||
}
|
||||
|
||||
$widgetConfig->model = $template;
|
||||
$widgetConfig->alias = $alias ?: 'form'.studly_case($type).md5($template->exists ? $template->getFileName() : uniqid());
|
||||
|
||||
return $this->makeWidget(Form::class, $widgetConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes the component settings so they are ready to be saved.
|
||||
* @param array $settings The new settings for this template.
|
||||
* @param array $prevSettings The previous settings for this template.
|
||||
*/
|
||||
protected function upgradeSettings($settings, $prevSettings): array
|
||||
{
|
||||
/*
|
||||
* Handle component usage
|
||||
*/
|
||||
$componentProperties = post('component_properties');
|
||||
$componentNames = post('component_names');
|
||||
$componentAliases = post('component_aliases');
|
||||
|
||||
if ($componentProperties !== null) {
|
||||
if ($componentNames === null || $componentAliases === null) {
|
||||
throw new ApplicationException(Lang::get('cms::lang.component.invalid_request'));
|
||||
}
|
||||
|
||||
$count = count($componentProperties);
|
||||
if (count($componentNames) != $count || count($componentAliases) != $count) {
|
||||
throw new ApplicationException(Lang::get('cms::lang.component.invalid_request'));
|
||||
}
|
||||
|
||||
for ($index = 0; $index < $count; $index++) {
|
||||
$componentName = $componentNames[$index];
|
||||
$componentAlias = $componentAliases[$index];
|
||||
|
||||
$isSoftComponent = (substr($componentAlias, 0, 1) === '@');
|
||||
$componentName = ltrim($componentName, '@');
|
||||
$componentAlias = ltrim($componentAlias, '@');
|
||||
|
||||
if ($componentAlias !== $componentName) {
|
||||
$section = $componentName . ' ' . $componentAlias;
|
||||
} else {
|
||||
$section = $componentName;
|
||||
}
|
||||
if ($isSoftComponent) {
|
||||
$section = '@' . $section;
|
||||
}
|
||||
|
||||
$properties = json_decode($componentProperties[$index], true);
|
||||
unset($properties['oc.alias'], $properties['inspectorProperty'], $properties['inspectorClassName']);
|
||||
|
||||
if (!$properties) {
|
||||
$oldComponentSettings = array_key_exists($section, $prevSettings['components'])
|
||||
? $prevSettings['components'][$section]
|
||||
: null;
|
||||
if ($isSoftComponent && $oldComponentSettings) {
|
||||
$settings[$section] = $oldComponentSettings;
|
||||
} else {
|
||||
$settings[$section] = $properties;
|
||||
}
|
||||
} else {
|
||||
$settings[$section] = $properties;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Handle view bag
|
||||
*/
|
||||
$viewBag = post('viewBag');
|
||||
if ($viewBag !== null) {
|
||||
$settings['viewBag'] = $viewBag;
|
||||
}
|
||||
|
||||
/**
|
||||
* @event cms.template.processSettingsBeforeSave
|
||||
* Fires before a CMS template (page|partial|layout|content|asset) is saved and provides an opportunity to interact with the settings data. `$dataHolder` = {settings: []}
|
||||
*
|
||||
* Example usage:
|
||||
*
|
||||
* Event::listen('cms.template.processSettingsBeforeSave', function ((\Cms\Controllers\Index) $controller, (object) $dataHolder) {
|
||||
* // Make some modifications to the $dataHolder object
|
||||
* });
|
||||
*
|
||||
* Or
|
||||
*
|
||||
* $CmsIndexController->bindEvent('template.processSettingsBeforeSave', function ((object) $dataHolder) {
|
||||
* // Make some modifications to the $dataHolder object
|
||||
* });
|
||||
*
|
||||
*/
|
||||
$dataHolder = (object) ['settings' => $settings];
|
||||
$this->fireSystemEvent('cms.template.processSettingsBeforeSave', [$dataHolder]);
|
||||
|
||||
return $dataHolder->settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a given component by its alias.
|
||||
*
|
||||
* If found, this will return the component's name, alias and properties.
|
||||
*
|
||||
* @param string $aliasQuery The alias to search for
|
||||
* @param array $components The array of components to look within.
|
||||
*/
|
||||
protected function findComponentByAlias(string $aliasQuery, array $components = []): ?array
|
||||
{
|
||||
$found = null;
|
||||
|
||||
foreach ($components as $name => $properties) {
|
||||
list($name, $alias) = strpos($name, ' ') ? explode(' ', $name) : [$name, $name];
|
||||
|
||||
if (ltrim($alias, '@') === ltrim($aliasQuery, '@')) {
|
||||
$found = [
|
||||
'name' => ltrim($name, '@'),
|
||||
'alias' => $alias,
|
||||
'properties' => $properties
|
||||
];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return $found;
|
||||
}
|
||||
|
||||
/**
|
||||
* Binds the active form widget to the controller
|
||||
*/
|
||||
protected function bindFormWidgetToController(): void
|
||||
{
|
||||
$alias = Request::input('formWidgetAlias');
|
||||
$type = Request::input('templateType');
|
||||
if (!empty(Request::input('templatePath'))) {
|
||||
$object = $this->loadTemplate($type, Request::input('templatePath'));
|
||||
} else {
|
||||
$object = $this->createTemplate($type);
|
||||
}
|
||||
$widget = $this->makeTemplateFormWidget($type, $object, $alias);
|
||||
|
||||
$widget->bindToController();
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces Windows style (/r/n) line endings with unix style (/n)
|
||||
* line endings.
|
||||
*/
|
||||
protected function convertLineEndings(string $markup): string
|
||||
{
|
||||
return str_replace(["\r\n", "\r"], "\n", $markup);
|
||||
}
|
||||
}
|
||||
22
modules/cms/controllers/Media.php
Normal file
22
modules/cms/controllers/Media.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php namespace Cms\Controllers;
|
||||
|
||||
use Backend\Controllers\Media as MediaController;
|
||||
|
||||
/**
|
||||
* CMS Media Manager
|
||||
*
|
||||
* @package winter\wn-cms-module
|
||||
* @author Alexey Bobkov, Samuel Georges
|
||||
* @deprecated Use Backend\Controllers\Media. Remove if year >= 2020.
|
||||
*/
|
||||
class Media extends MediaController
|
||||
{
|
||||
/**
|
||||
* Constructor.
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
traceLog('Controller Cms\Controllers\Media has been deprecated, use ' . MediaController::class . ' instead.');
|
||||
parent::__construct();
|
||||
}
|
||||
}
|
||||
81
modules/cms/controllers/ThemeLogs.php
Normal file
81
modules/cms/controllers/ThemeLogs.php
Normal file
@@ -0,0 +1,81 @@
|
||||
<?php namespace Cms\Controllers;
|
||||
|
||||
use Lang;
|
||||
use Flash;
|
||||
use BackendMenu;
|
||||
use Backend\Classes\Controller;
|
||||
use System\Classes\SettingsManager;
|
||||
use Cms\Models\ThemeLog;
|
||||
|
||||
/**
|
||||
* Request Logs controller
|
||||
*
|
||||
* @package winter\wn-system-module
|
||||
* @author Alexey Bobkov, Samuel Georges
|
||||
*/
|
||||
class ThemeLogs extends Controller
|
||||
{
|
||||
/**
|
||||
* @var array Extensions implemented by this controller.
|
||||
*/
|
||||
public $implement = [
|
||||
\Backend\Behaviors\FormController::class,
|
||||
\Backend\Behaviors\ListController::class,
|
||||
];
|
||||
|
||||
/**
|
||||
* @var array Permissions required to view this page.
|
||||
*/
|
||||
public $requiredPermissions = ['system.access_logs'];
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
|
||||
BackendMenu::setContext('Winter.System', 'system', 'settings');
|
||||
SettingsManager::setContext('Winter.Cms', 'theme_logs');
|
||||
}
|
||||
|
||||
public function index_onRefresh()
|
||||
{
|
||||
return $this->listRefresh();
|
||||
}
|
||||
|
||||
public function index_onEmptyLog()
|
||||
{
|
||||
ThemeLog::truncate();
|
||||
Flash::success(Lang::get('cms::lang.theme_log.empty_success'));
|
||||
return $this->listRefresh();
|
||||
}
|
||||
|
||||
public function index_onDelete()
|
||||
{
|
||||
if (($checkedIds = post('checked')) && is_array($checkedIds) && count($checkedIds)) {
|
||||
foreach ($checkedIds as $recordId) {
|
||||
if (!$record = ThemeLog::find($recordId)) {
|
||||
continue;
|
||||
}
|
||||
$record->delete();
|
||||
}
|
||||
|
||||
Flash::success(Lang::get('backend::lang.list.delete_selected_success'));
|
||||
}
|
||||
else {
|
||||
Flash::error(Lang::get('backend::lang.list.delete_selected_empty'));
|
||||
}
|
||||
|
||||
return $this->listRefresh();
|
||||
}
|
||||
|
||||
public function preview($id)
|
||||
{
|
||||
$this->addCss('/modules/cms/assets/css/themelogs/template-diff.css', 'core');
|
||||
$this->addJs('/modules/cms/assets/vendor/jsdiff/diff.js', 'core');
|
||||
$this->addJs('/modules/cms/assets/js/themelogs/template-diff.js', 'core');
|
||||
|
||||
return $this->asExtension('FormController')->preview($id);
|
||||
}
|
||||
}
|
||||
144
modules/cms/controllers/ThemeOptions.php
Normal file
144
modules/cms/controllers/ThemeOptions.php
Normal file
@@ -0,0 +1,144 @@
|
||||
<?php namespace Cms\Controllers;
|
||||
|
||||
use Backend;
|
||||
use BackendMenu;
|
||||
use ApplicationException;
|
||||
use Cms\Models\ThemeData;
|
||||
use Cms\Classes\Theme as CmsTheme;
|
||||
use System\Classes\SettingsManager;
|
||||
use Backend\Classes\Controller;
|
||||
use Exception;
|
||||
|
||||
/**
|
||||
* Theme customization controller
|
||||
*
|
||||
* @package winter\wn-backend-module
|
||||
* @author Alexey Bobkov, Samuel Georges
|
||||
*
|
||||
*/
|
||||
class ThemeOptions extends Controller
|
||||
{
|
||||
/**
|
||||
* @var array Extensions implemented by this controller.
|
||||
*/
|
||||
public $implement = [
|
||||
\Backend\Behaviors\FormController::class,
|
||||
];
|
||||
|
||||
/**
|
||||
* @var array Permissions required to view this page.
|
||||
*/
|
||||
public $requiredPermissions = ['cms.manage_themes', 'cms.manage_theme_options'];
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
|
||||
$this->pageTitle = 'cms::lang.theme.settings_menu';
|
||||
|
||||
BackendMenu::setContext('Winter.System', 'system', 'settings');
|
||||
SettingsManager::setContext('Winter.Cms', 'theme');
|
||||
}
|
||||
|
||||
public function update($dirName = null)
|
||||
{
|
||||
$dirName = $this->getDirName($dirName);
|
||||
|
||||
try {
|
||||
$model = $this->getThemeData($dirName);
|
||||
|
||||
$this->asExtension('FormController')->update($model->id);
|
||||
|
||||
$this->vars['hasCustomData'] = $this->hasThemeData($dirName);
|
||||
}
|
||||
catch (Exception $ex) {
|
||||
$this->handleError($ex);
|
||||
}
|
||||
}
|
||||
|
||||
public function update_onSave($dirName = null)
|
||||
{
|
||||
$model = $this->getThemeData($this->getDirName($dirName));
|
||||
$result = $this->asExtension('FormController')->update_onSave($model->id);
|
||||
|
||||
// Redirect close requests to the settings index when user doesn't have access
|
||||
// to go back to the theme selection page
|
||||
if (!$this->user->hasAccess('cms.manage_themes') && input('close')) {
|
||||
$result = Backend::redirect('system/settings');
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function update_onResetDefault($dirName = null)
|
||||
{
|
||||
$model = $this->getThemeData($this->getDirName($dirName));
|
||||
$model->delete();
|
||||
|
||||
return Backend::redirect('cms/themeoptions/update/'.$dirName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add form fields defined in theme.yaml
|
||||
*/
|
||||
public function formExtendFieldsBefore($form)
|
||||
{
|
||||
$model = $form->model;
|
||||
$theme = $this->findThemeObject($model->theme);
|
||||
$form->config = $this->mergeConfig($form->config, $theme->getFormConfig());
|
||||
$form->init();
|
||||
}
|
||||
|
||||
//
|
||||
// Helpers
|
||||
//
|
||||
|
||||
/**
|
||||
* Default to the active theme if user doesn't have access to manage all themes
|
||||
*
|
||||
* @param string $dirName
|
||||
* @return string
|
||||
*/
|
||||
protected function getDirName(?string $dirName = null)
|
||||
{
|
||||
/*
|
||||
* Only the active theme can be managed without this permission
|
||||
*/
|
||||
if ($dirName && !$this->user->hasAccess('cms.manage_themes')) {
|
||||
$dirName = null;
|
||||
}
|
||||
|
||||
if ($dirName === null) {
|
||||
$dirName = CmsTheme::getActiveThemeCode();
|
||||
}
|
||||
|
||||
return $dirName;
|
||||
}
|
||||
|
||||
protected function hasThemeData($dirName)
|
||||
{
|
||||
return $this->findThemeObject($dirName)->hasCustomData();
|
||||
}
|
||||
|
||||
protected function getThemeData($dirName)
|
||||
{
|
||||
$theme = $this->findThemeObject($dirName);
|
||||
return ThemeData::forTheme($theme);
|
||||
}
|
||||
|
||||
protected function findThemeObject($name = null)
|
||||
{
|
||||
if ($name === null) {
|
||||
$name = post('theme');
|
||||
}
|
||||
|
||||
if (!$name || (!$theme = CmsTheme::load($name))) {
|
||||
throw new ApplicationException(trans('cms::lang.theme.not_found_name', ['name' => $name]));
|
||||
}
|
||||
|
||||
return $theme;
|
||||
}
|
||||
}
|
||||
359
modules/cms/controllers/Themes.php
Normal file
359
modules/cms/controllers/Themes.php
Normal file
@@ -0,0 +1,359 @@
|
||||
<?php namespace Cms\Controllers;
|
||||
|
||||
use ApplicationException;
|
||||
use Artisan;
|
||||
use Backend;
|
||||
use Backend\Classes\Controller;
|
||||
use Backend\Models\BrandSetting;
|
||||
use Backend\Widgets\Form;
|
||||
use BackendMenu;
|
||||
use Cms\Classes\Theme as CmsTheme;
|
||||
use Cms\Classes\ThemeManager;
|
||||
use Cms\Helpers\Cms as CmsHelper;
|
||||
use Cms\Models\ThemeExport;
|
||||
use Cms\Models\ThemeImport;
|
||||
use Exception;
|
||||
use File;
|
||||
use Flash;
|
||||
use Lang;
|
||||
use Redirect;
|
||||
use System\Classes\SettingsManager;
|
||||
use Url;
|
||||
use ValidationException;
|
||||
use Winter\Storm\Support\Str;
|
||||
|
||||
/**
|
||||
* Theme selector controller
|
||||
*
|
||||
* @package winter\wn-backend-module
|
||||
* @author Alexey Bobkov, Samuel Georges
|
||||
*
|
||||
*/
|
||||
class Themes extends Controller
|
||||
{
|
||||
/**
|
||||
* @var array Permissions required to view this page.
|
||||
*/
|
||||
public $requiredPermissions = [
|
||||
'cms.manage_themes',
|
||||
'cms.manage_theme_options',
|
||||
];
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
|
||||
$this->addCss('/modules/cms/assets/css/winter.theme-selector.css', 'core');
|
||||
|
||||
$this->pageTitle = 'cms::lang.theme.settings_menu';
|
||||
BackendMenu::setContext('Winter.System', 'system', 'settings');
|
||||
SettingsManager::setContext('Winter.Cms', 'theme');
|
||||
|
||||
/*
|
||||
* Custom redirect for unauthorized request
|
||||
*/
|
||||
$this->bindEvent('page.beforeDisplay', function () {
|
||||
if (!$this->user->hasAccess('cms.manage_themes')) {
|
||||
return Backend::redirect('cms/themeoptions/update');
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
* Enable AJAX for Form widgets
|
||||
*/
|
||||
if (post('mode') === 'import') {
|
||||
$this->makeImportFormWidget($this->findThemeObject())->bindToController();
|
||||
}
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
$this->bodyClass = 'compact-container';
|
||||
}
|
||||
|
||||
public function index_onSetActiveTheme()
|
||||
{
|
||||
CmsTheme::setActiveTheme(post('theme'));
|
||||
|
||||
return [
|
||||
'#theme-list' => $this->makePartial('theme_list')
|
||||
];
|
||||
}
|
||||
|
||||
public function index_onDelete()
|
||||
{
|
||||
ThemeManager::instance()->deleteTheme(post('theme'));
|
||||
|
||||
Flash::success(Lang::get('cms::lang.theme.delete_theme_success'));
|
||||
return Redirect::refresh();
|
||||
}
|
||||
|
||||
//
|
||||
// Theme properties
|
||||
//
|
||||
|
||||
public function index_onLoadFieldsForm()
|
||||
{
|
||||
$theme = $this->findThemeObject();
|
||||
$this->vars['widget'] = $this->makeFieldsFormWidget($theme);
|
||||
$this->vars['themeDir'] = $theme->getDirName();
|
||||
|
||||
return $this->makePartial('theme_fields_form');
|
||||
}
|
||||
|
||||
public function index_onSaveFields()
|
||||
{
|
||||
$theme = $this->findThemeObject();
|
||||
$widget = $this->makeFieldsFormWidget($theme);
|
||||
$theme->writeConfig($widget->getSaveData());
|
||||
|
||||
return ['#themeListItem-'.$theme->getId() => $this->makePartial('theme_list_item', ['theme' => $theme])];
|
||||
}
|
||||
|
||||
protected function makeFieldsFormWidget($theme)
|
||||
{
|
||||
$widgetConfig = $this->makeConfig('~/modules/cms/classes/theme/fields.yaml');
|
||||
$widgetConfig->alias = 'form'.studly_case($theme->getDirName());
|
||||
$widgetConfig->model = $theme;
|
||||
$widgetConfig->data = $theme->getConfig();
|
||||
$widgetConfig->data['dir_name'] = $theme->getDirName();
|
||||
$widgetConfig->arrayName = 'Theme';
|
||||
$widgetConfig->context = 'update';
|
||||
|
||||
return $this->makeWidget(Form::class, $widgetConfig);
|
||||
}
|
||||
|
||||
//
|
||||
// Create theme
|
||||
//
|
||||
|
||||
public function index_onLoadCreateForm()
|
||||
{
|
||||
$this->vars['widget'] = $this->makeCreateFormWidget();
|
||||
return $this->makePartial('theme_create_form');
|
||||
}
|
||||
|
||||
public function index_onCreate()
|
||||
{
|
||||
$widget = $this->makeCreateFormWidget();
|
||||
$data = $widget->getSaveData();
|
||||
$newDirName = trim(array_get($data, 'dir_name'));
|
||||
$destinationPath = themes_path($newDirName);
|
||||
|
||||
$data = array_except($data, 'dir_name');
|
||||
|
||||
if (!strlen(trim(array_get($data, 'name')))) {
|
||||
throw new ValidationException(['name' => Lang::get('cms::lang.theme.create_theme_required_name')]);
|
||||
}
|
||||
|
||||
if (!CmsTheme::isValidDirName($newDirName)) {
|
||||
throw new ValidationException(['dir_name' => Lang::get('cms::lang.theme.dir_name_invalid')]);
|
||||
}
|
||||
|
||||
if (File::isDirectory($destinationPath)) {
|
||||
throw new ValidationException(['dir_name' => Lang::get('cms::lang.theme.dir_name_taken')]);
|
||||
}
|
||||
|
||||
switch ($data['scaffold']) {
|
||||
case 'empty':
|
||||
File::makeDirectory($destinationPath);
|
||||
File::makeDirectory($destinationPath.'/assets');
|
||||
File::makeDirectory($destinationPath.'/content');
|
||||
File::makeDirectory($destinationPath.'/layouts');
|
||||
File::makeDirectory($destinationPath.'/pages');
|
||||
File::makeDirectory($destinationPath.'/partials');
|
||||
File::put($destinationPath.'/theme.yaml', '');
|
||||
break;
|
||||
default:
|
||||
try {
|
||||
Artisan::call('create:theme', [
|
||||
'theme' => $newDirName,
|
||||
'scaffold' => $data['scaffold']
|
||||
]);
|
||||
} catch (\Exception $ex) {
|
||||
throw new ApplicationException($ex->getMessage());
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
unset($data['scaffold']);
|
||||
|
||||
$theme = CmsTheme::load($newDirName);
|
||||
$theme->writeConfig($data);
|
||||
|
||||
Flash::success(Lang::get('cms::lang.theme.create_theme_success'));
|
||||
return Redirect::refresh();
|
||||
}
|
||||
|
||||
protected function makeCreateFormWidget()
|
||||
{
|
||||
$theme = new CmsTheme;
|
||||
$theme->setDirName('newtheme');
|
||||
$widgetConfig = $this->makeConfig('~/modules/cms/classes/theme/fields.yaml');
|
||||
$widgetConfig->alias = 'formCreateTheme';
|
||||
$widgetConfig->model = $theme;
|
||||
$widgetConfig->arrayName = 'Theme';
|
||||
$widgetConfig->context = 'create';
|
||||
|
||||
// Setup default values
|
||||
$name = BrandSetting::get('app_name');
|
||||
$slug = Str::slug($name);
|
||||
$url = Url::to('/');
|
||||
$widgetConfig->data = [
|
||||
'name' => $name,
|
||||
'dir_name' => $slug,
|
||||
'description' => Lang::get('cms::lang.theme.default_description', ['url' => $url]),
|
||||
'code' => $slug,
|
||||
'author' => $this->user->full_name,
|
||||
'homepage' => $url,
|
||||
];
|
||||
|
||||
return $this->makeWidget('Backend\Widgets\Form', $widgetConfig);
|
||||
}
|
||||
|
||||
//
|
||||
// Duplicate
|
||||
//
|
||||
|
||||
public function index_onLoadDuplicateForm()
|
||||
{
|
||||
$theme = $this->findThemeObject();
|
||||
$this->vars['themeDir'] = $theme->getDirName();
|
||||
|
||||
return $this->makePartial('theme_duplicate_form');
|
||||
}
|
||||
|
||||
public function index_onDuplicateTheme()
|
||||
{
|
||||
$theme = $this->findThemeObject();
|
||||
$newDirName = trim(post('new_dir_name'));
|
||||
$sourcePath = $theme->getPath();
|
||||
$destinationPath = themes_path().'/'.$newDirName;
|
||||
|
||||
if (!CmsTheme::isValidDirName($newDirName)) {
|
||||
throw new ValidationException(['new_dir_name' => Lang::get('cms::lang.theme.dir_name_invalid')]);
|
||||
}
|
||||
|
||||
if (File::isDirectory($destinationPath)) {
|
||||
throw new ValidationException(['new_dir_name' => Lang::get('cms::lang.theme.dir_name_taken')]);
|
||||
}
|
||||
|
||||
File::copyDirectory($sourcePath, $destinationPath);
|
||||
$newTheme = CmsTheme::load($newDirName);
|
||||
$newName = $newTheme->getConfigValue('name') . ' - Copy';
|
||||
$newTheme->writeConfig(['name' => $newName]);
|
||||
|
||||
Flash::success(Lang::get('cms::lang.theme.duplicate_theme_success'));
|
||||
return Redirect::refresh();
|
||||
}
|
||||
|
||||
//
|
||||
// Theme export
|
||||
//
|
||||
|
||||
public function index_onLoadExportForm()
|
||||
{
|
||||
$theme = $this->findThemeObject();
|
||||
$this->vars['widget'] = $this->makeExportFormWidget($theme);
|
||||
$this->vars['themeDir'] = $theme->getDirName();
|
||||
|
||||
return $this->makePartial('theme_export_form');
|
||||
}
|
||||
|
||||
public function index_onExport()
|
||||
{
|
||||
$theme = $this->findThemeObject();
|
||||
$widget = $this->makeExportFormWidget($theme);
|
||||
|
||||
$model = new ThemeExport;
|
||||
$file = $model->export($theme, $widget->getSaveData());
|
||||
|
||||
return Backend::redirect('cms/themes/download/'.$file.'/'.$theme->getDirName().'.zip');
|
||||
}
|
||||
|
||||
public function download($name, $outputName = null)
|
||||
{
|
||||
try {
|
||||
$this->pageTitle = 'Download theme export archive';
|
||||
return ThemeExport::download($name, $outputName);
|
||||
}
|
||||
catch (Exception $ex) {
|
||||
$this->handleError($ex);
|
||||
}
|
||||
}
|
||||
|
||||
protected function makeExportFormWidget($theme)
|
||||
{
|
||||
$widgetConfig = $this->makeConfig('~/modules/cms/models/themeexport/fields.yaml');
|
||||
$widgetConfig->alias = 'form'.studly_case($theme->getDirName());
|
||||
$widgetConfig->model = new ThemeExport;
|
||||
$widgetConfig->model->theme = $theme;
|
||||
$widgetConfig->arrayName = 'ThemeExport';
|
||||
|
||||
return $this->makeWidget('Backend\Widgets\Form', $widgetConfig);
|
||||
}
|
||||
|
||||
//
|
||||
// Theme import
|
||||
//
|
||||
|
||||
public function index_onLoadImportForm()
|
||||
{
|
||||
if (CmsHelper::safeModeEnabled()) {
|
||||
throw new ApplicationException(Lang::get('cms::lang.cms_object.safe_mode_enabled'));
|
||||
}
|
||||
|
||||
$theme = $this->findThemeObject();
|
||||
$this->vars['widget'] = $this->makeImportFormWidget($theme);
|
||||
$this->vars['themeDir'] = $theme->getDirName();
|
||||
|
||||
return $this->makePartial('theme_import_form');
|
||||
}
|
||||
|
||||
public function index_onImport()
|
||||
{
|
||||
if (CmsHelper::safeModeEnabled()) {
|
||||
throw new ApplicationException(Lang::get('cms::lang.cms_object.safe_mode_enabled'));
|
||||
}
|
||||
|
||||
$theme = $this->findThemeObject();
|
||||
$widget = $this->makeImportFormWidget($theme);
|
||||
|
||||
$model = new ThemeImport;
|
||||
$model->import($theme, $widget->getSaveData(), $widget->getSessionKey());
|
||||
|
||||
Flash::success(Lang::get('cms::lang.theme.import_theme_success'));
|
||||
return Redirect::refresh();
|
||||
}
|
||||
|
||||
protected function makeImportFormWidget($theme)
|
||||
{
|
||||
$widgetConfig = $this->makeConfig('~/modules/cms/models/themeimport/fields.yaml');
|
||||
$widgetConfig->alias = 'form'.studly_case($theme->getDirName());
|
||||
$widgetConfig->model = new ThemeImport;
|
||||
$widgetConfig->model->theme = $theme;
|
||||
$widgetConfig->arrayName = 'ThemeImport';
|
||||
|
||||
return $this->makeWidget('Backend\Widgets\Form', $widgetConfig);
|
||||
}
|
||||
|
||||
//
|
||||
// Helpers
|
||||
//
|
||||
|
||||
protected function findThemeObject($name = null)
|
||||
{
|
||||
if ($name === null) {
|
||||
$name = post('theme');
|
||||
}
|
||||
|
||||
if (!$name || (!$theme = CmsTheme::load($name))) {
|
||||
throw new ApplicationException(Lang::get('cms::lang.theme.not_found_name', ['name' => $name]));
|
||||
}
|
||||
|
||||
return $theme;
|
||||
}
|
||||
}
|
||||
14
modules/cms/controllers/index/_button_commit.php
Normal file
14
modules/cms/controllers/index/_button_commit.php
Normal file
@@ -0,0 +1,14 @@
|
||||
<button
|
||||
type="button"
|
||||
class="
|
||||
btn btn-danger wn-icon-download
|
||||
<?php if (!$canCommit): ?>
|
||||
hide
|
||||
<?php endif ?>
|
||||
"
|
||||
data-request="onCommit"
|
||||
data-request-confirm="<?= e(trans('cms::lang.editor.commit_confirm')) ?>"
|
||||
data-load-indicator="<?= e(trans('cms::lang.editor.committing')) ?>"
|
||||
data-control="commit-button">
|
||||
<?= e(trans('cms::lang.editor.commit')) ?>
|
||||
</button>
|
||||
8
modules/cms/controllers/index/_button_lastmodified.php
Normal file
8
modules/cms/controllers/index/_button_lastmodified.php
Normal file
@@ -0,0 +1,8 @@
|
||||
<?php if (isset($lastModified)): ?>
|
||||
<span
|
||||
class="btn empty wn-icon-calendar"
|
||||
title="<?= e(trans('backend::lang.media.last_modified')) ?>: <?= $lastModified ?>"
|
||||
data-toggle="tooltip"
|
||||
data-placement="right">
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
14
modules/cms/controllers/index/_button_reset.php
Normal file
14
modules/cms/controllers/index/_button_reset.php
Normal file
@@ -0,0 +1,14 @@
|
||||
<button
|
||||
type="button"
|
||||
class="
|
||||
btn btn-danger wn-icon-bomb
|
||||
<?php if (!$canReset): ?>
|
||||
hide
|
||||
<?php endif ?>
|
||||
"
|
||||
data-request="onReset"
|
||||
data-request-confirm="<?= e(trans('cms::lang.editor.reset_confirm')) ?>"
|
||||
data-load-indicator="<?= e(trans('cms::lang.editor.resetting')) ?>"
|
||||
data-control="reset-button">
|
||||
<?= e(trans('cms::lang.editor.reset')) ?>
|
||||
</button>
|
||||
19
modules/cms/controllers/index/_common_toolbar_actions.php
Normal file
19
modules/cms/controllers/index/_common_toolbar_actions.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?= $this->makePartial('button_commit'); ?>
|
||||
|
||||
<?= $this->makePartial('button_reset'); ?>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="
|
||||
btn btn-danger empty wn-icon-trash-o
|
||||
<?php if (!$templatePath): ?>
|
||||
hide
|
||||
<?php endif ?>
|
||||
"
|
||||
data-request="onDelete"
|
||||
data-request-confirm="<?= e(trans('cms::lang.' . $toolbarSource . '.delete_confirm_single')) ?>"
|
||||
data-request-success="$.wn.cmsPage.updateTemplateList('<?= $toolbarSource ?>'); $(this).trigger('close.oc.tab', [{force: true}])"
|
||||
data-control="delete-button">
|
||||
</button>
|
||||
|
||||
<?= $this->makePartial('button_lastmodified'); ?>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user