Update to Kirby 5

This commit is contained in:
Paul Nicoué 2025-07-11 14:41:34 +02:00
parent 5d9979fca8
commit 0fefc5e2e1
472 changed files with 30853 additions and 10301 deletions

View file

@ -75,7 +75,6 @@ return [
// Any of these might be removed at any point in the future
'kirby\cms\asset' => 'Kirby\Filesystem\Asset',
'kirby\cms\content' => 'Kirby\Content\Content',
'kirby\cms\contenttranslation' => 'Kirby\Content\ContentTranslation',
'kirby\cms\dir' => 'Kirby\Filesystem\Dir',
'kirby\cms\filename' => 'Kirby\Filesystem\Filename',
'kirby\cms\filefoundation' => 'Kirby\Filesystem\IsFile',
@ -83,6 +82,9 @@ return [
'kirby\cms\form' => 'Kirby\Form\Form',
'kirby\cms\kirbytag' => 'Kirby\Text\KirbyTag',
'kirby\cms\kirbytags' => 'Kirby\Text\KirbyTags',
'kirby\cms\plugin' => 'Kirby\Plugin\Plugin',
'kirby\cms\pluginasset' => 'Kirby\Plugin\Asset',
'kirby\cms\pluginassets' => 'Kirby\Plugin\Assets',
'kirby\cms\template' => 'Kirby\Template\Template',
'kirby\form\options' => 'Kirby\Option\Options',
'kirby\form\optionsapi' => 'Kirby\Option\OptionsApi',

View file

@ -11,17 +11,17 @@ return function () {
$auth->type($allowImpersonation) === 'session' &&
$auth->csrf() === false
) {
throw new AuthException('Unauthenticated');
throw new AuthException(message: 'Unauthenticated');
}
// get user from session or basic auth
if ($user = $auth->user(null, $allowImpersonation)) {
if ($user->role()->permissions()->for('access', 'panel') === false) {
throw new AuthException(['key' => 'access.panel']);
throw new AuthException(key: 'access.panel');
}
return $user;
}
throw new AuthException('Unauthenticated');
throw new AuthException(message: 'Unauthenticated');
};

View file

@ -4,25 +4,25 @@
* Api Routes Definitions
*/
return function ($kirby) {
$routes = array_merge(
include __DIR__ . '/routes/auth.php',
include __DIR__ . '/routes/pages.php',
include __DIR__ . '/routes/roles.php',
include __DIR__ . '/routes/site.php',
include __DIR__ . '/routes/users.php',
include __DIR__ . '/routes/files.php',
include __DIR__ . '/routes/lock.php',
include __DIR__ . '/routes/system.php',
include __DIR__ . '/routes/translations.php'
);
$routes = [
...include __DIR__ . '/routes/auth.php',
...include __DIR__ . '/routes/changes.php',
...include __DIR__ . '/routes/pages.php',
...include __DIR__ . '/routes/roles.php',
...include __DIR__ . '/routes/site.php',
...include __DIR__ . '/routes/users.php',
...include __DIR__ . '/routes/files.php',
...include __DIR__ . '/routes/system.php',
...include __DIR__ . '/routes/translations.php'
];
// only add the language routes if the
// multi language setup is activated
if ($kirby->option('languages', false) !== false) {
$routes = array_merge(
$routes,
include __DIR__ . '/routes/languages.php'
);
$routes = [
...$routes,
...include __DIR__ . '/routes/languages.php'
];
}
return $routes;

View file

@ -15,7 +15,9 @@ return [
return $this->resolve($user)->view('auth');
}
throw new NotFoundException('The user cannot be found');
throw new NotFoundException(
message: 'The user cannot be found'
);
}
],
[
@ -27,7 +29,9 @@ return [
// csrf token check
if ($auth->type() === 'session' && $auth->csrf() === false) {
throw new InvalidArgumentException('Invalid CSRF token');
throw new InvalidArgumentException(
message: 'Invalid CSRF token'
);
}
$user = $auth->verifyChallenge($this->requestBody('code'));
@ -49,7 +53,9 @@ return [
// csrf token check
if ($auth->type() === 'session' && $auth->csrf() === false) {
throw new InvalidArgumentException('Invalid CSRF token');
throw new InvalidArgumentException(
message: 'Invalid CSRF token'
);
}
$email = $this->requestBody('email');
@ -58,7 +64,9 @@ return [
if ($password) {
if (isset($methods['password']) !== true) {
throw new InvalidArgumentException('Login with password is not enabled');
throw new InvalidArgumentException(
message: 'Login with password is not enabled'
);
}
if (
@ -73,7 +81,9 @@ return [
$mode = match (true) {
isset($methods['code']) => 'login',
isset($methods['password-reset']) => 'password-reset',
default => throw new InvalidArgumentException('Login without password is not enabled')
default => throw new InvalidArgumentException(
message: 'Login without password is not enabled'
)
};
$status = $auth->createChallenge($email, $long, $mode);

View file

@ -0,0 +1,37 @@
<?php
use Kirby\Api\Controller\Changes;
use Kirby\Cms\App;
use Kirby\Cms\Find;
return [
[
'pattern' => '(:all)/changes/discard',
'method' => 'POST',
'action' => function (string $path) {
return Changes::discard(
model: Find::parent($path),
);
}
],
[
'pattern' => '(:all)/changes/publish',
'method' => 'POST',
'action' => function (string $path) {
return Changes::publish(
model: Find::parent($path),
input: App::instance()->request()->get()
);
}
],
[
'pattern' => '(:all)/changes/save',
'method' => 'POST',
'action' => function (string $path) {
return Changes::save(
model: Find::parent($path),
input: App::instance()->request()->get()
);
}
],
];

View file

@ -47,7 +47,7 @@ return [
// move_uploaded_file() not working with unit test
// @codeCoverageIgnoreStart
return $this->upload(function ($source, $filename) use ($path) {
// move the source file from the temp dir
// move the source file to the content folder
return $this->parent($path)->createFile([
'content' => [
'sort' => $this->requestBody('sort')

View file

@ -1,56 +0,0 @@
<?php
/**
* Content Lock Routes
*/
use Kirby\Exception\NotFoundException;
return [
[
'pattern' => '(:all)/lock',
'method' => 'GET',
'action' => function (string $path) {
return [
'lock' => $this->parent($path)->lock()?->toArray() ?? false
];
}
],
[
'pattern' => '(:all)/lock',
'method' => 'PATCH',
'action' => function (string $path) {
return $this->parent($path)->lock()?->create();
}
],
[
'pattern' => '(:all)/lock',
'method' => 'DELETE',
'action' => function (string $path) {
try {
return $this->parent($path)->lock()?->remove();
} catch (NotFoundException) {
return true;
}
}
],
[
'pattern' => '(:all)/unlock',
'method' => 'PATCH',
'action' => function (string $path) {
return $this->parent($path)->lock()?->unlock();
}
],
[
'pattern' => '(:all)/unlock',
'method' => 'DELETE',
'action' => function (string $path) {
try {
return $this->parent($path)->lock()?->resolve();
} catch (NotFoundException) {
return true;
}
}
],
];

View file

@ -31,18 +31,6 @@ return [
];
}
],
[
'pattern' => 'system/method-test',
'method' => 'PATCH',
'action' => function () {
return [
'status' => match ($this->kirby()->request()->method()) {
'PATCH' => 'ok',
default => 'fail'
}
];
}
],
[
'pattern' => 'system/register',
'method' => 'POST',
@ -60,19 +48,27 @@ return [
// csrf token check
if ($auth->type() === 'session' && $auth->csrf() === false) {
throw new InvalidArgumentException('Invalid CSRF token');
throw new InvalidArgumentException(
message: 'Invalid CSRF token'
);
}
if ($system->isOk() === false) {
throw new Exception('The server is not setup correctly');
throw new Exception(
message: 'The server is not setup correctly'
);
}
if ($system->isInstallable() === false) {
throw new Exception('The Panel cannot be installed');
throw new Exception(
message: 'The Panel cannot be installed'
);
}
if ($system->isInstalled() === true) {
throw new Exception('The Panel is already installed');
throw new Exception(
message: 'The Panel is already installed'
);
}
// create the first user

View file

@ -86,18 +86,18 @@ return [
function ($source, $filename) use ($id) {
$type = F::type($filename);
if ($type !== 'image') {
throw new Exception([
'key' => 'file.type.invalid',
'data' => compact('type')
]);
throw new Exception(
key: 'file.type.invalid',
data: compact('type')
);
}
$mime = F::mime($source);
if (Str::startsWith($mime, 'image/') !== true) {
throw new Exception([
'key' => 'file.mime.invalid',
'data' => compact('mime')
]);
throw new Exception(
key: 'file.mime.invalid',
data: compact('mime')
);
}
// delete the old avatar
@ -184,7 +184,23 @@ return [
],
'method' => 'PATCH',
'action' => function (string $id) {
return $this->user($id)->changePassword($this->requestBody('password'));
$user = $this->user($id);
// validate password of acting user unless they have logged in to reset it;
// always validate password of acting user when changing password of other users
if ($this->session()->get('kirby.resetPassword') !== true || $this->user()->is($user) !== true) {
$this->user()->validatePassword($this->requestBody('currentPassword'));
}
$result = $user->changePassword($this->requestBody('password'));
// if we changed the password of the current user…
if ($user->isLoggedIn() === true) {
// …don't allow additional resets (now the password is known again)
$this->session()->remove('kirby.resetPassword');
}
return $result;
}
],
[

View file

@ -7,6 +7,7 @@ return function () {
'icon' => 'account',
'label' => I18n::translate('view.account'),
'search' => 'users',
'buttons' => require __DIR__ . '/account/buttons.php',
'dialogs' => require __DIR__ . '/account/dialogs.php',
'drawers' => require __DIR__ . '/account/drawers.php',
'dropdowns' => require __DIR__ . '/account/dropdowns.php',

View file

@ -0,0 +1,13 @@
<?php
use Kirby\Cms\App;
use Kirby\Cms\User;
use Kirby\Panel\Ui\Buttons\ViewButton;
return [
'user.theme' => function (App $kirby, User $user) {
if ($kirby->user()->is($user) === true) {
return new ViewButton(component: 'k-theme-view-button');
}
}
];

View file

@ -5,7 +5,6 @@ use Kirby\Panel\UserTotpEnableDialog;
$dialogs = require __DIR__ . '/../users/dialogs.php';
return [
// change email
'account.changeEmail' => [
'pattern' => '(account)/changeEmail',

View file

@ -7,8 +7,16 @@ return [
'pattern' => '(account)',
'options' => $dropdowns['user']['options']
],
'account.languages' => [
'pattern' => '(account)/languages',
'options' => $dropdowns['user.languages']['options']
],
'account.file' => [
'pattern' => '(account)/files/(:any)',
'options' => $dropdowns['user.file']['options']
],
'account.file.languages' => [
'pattern' => '(account)/files/(:any)/languages',
'options' => $files['language']
]
];

View file

@ -26,6 +26,9 @@ return [
[
'label' => I18n::translate('view.resetPassword')
]
],
'props' => [
'requirePassword' => App::instance()->session()->get('kirby.resetPassword') !== true
]
]
]

View file

@ -0,0 +1,14 @@
<?php
use Kirby\Cms\File;
use Kirby\Panel\Ui\Buttons\OpenButton;
use Kirby\Panel\Ui\Buttons\SettingsButton;
return [
'file.open' => function (File $file) {
return new OpenButton(link: $file->previewUrl());
},
'file.settings' => function (File $file) {
return new SettingsButton(model: $file);
}
];

View file

@ -45,13 +45,7 @@ return [
$oldUrl = $file->panel()->url(true);
$newUrl = $renamed->panel()->url(true);
$response = [
'event' => 'file.changeName',
'dispatch' => [
'content/move' => [
$oldUrl,
$newUrl
]
],
'event' => 'file.changeName'
];
// check for a necessary redirect after the filename has changed
@ -163,7 +157,6 @@ return [
return [
'event' => 'file.delete',
'dispatch' => ['content/remove' => [$url]],
'redirect' => $redirect
];
}

View file

@ -1,9 +1,14 @@
<?php
use Kirby\Cms\Find;
use Kirby\Panel\Ui\Buttons\LanguagesDropdown;
return [
'file' => function (string $parent, string $filename) {
return Find::file($parent, $filename)->panel()->dropdown();
},
'language' => function (string $parent, string $filename) {
$file = Find::file($parent, $filename);
return (new LanguagesDropdown($file))->options();
}
];

View file

@ -1,12 +1,13 @@
<?php
use Kirby\Panel\Lab\Doc;
use Kirby\Panel\Lab\Docs;
return [
'lab.docs' => [
'pattern' => 'lab/docs/(:any)',
'load' => function (string $component) {
if (Docs::installed() === false) {
if (Docs::isInstalled() === false) {
return [
'component' => 'k-text-drawer',
'props' => [
@ -15,14 +16,12 @@ return [
];
}
$docs = new Docs($component);
return [
'component' => 'k-lab-docs-drawer',
'props' => [
'icon' => 'book',
'title' => $component,
'docs' => $docs->toArray()
'docs' => Doc::factory($component)->toArray()
]
];
},

View file

@ -2,6 +2,7 @@
use Kirby\Cms\App;
use Kirby\Panel\Lab\Category;
use Kirby\Panel\Lab\Doc;
use Kirby\Panel\Lab\Docs;
return [
@ -12,7 +13,7 @@ return [
'component' => 'k-lab-index-view',
'props' => [
'categories' => Category::all(),
'info' => Category::installed() ? null : 'The default Lab examples are not installed.',
'info' => Category::isInstalled() ? null : 'The default Lab examples are not installed.',
'tab' => 'examples',
],
];
@ -21,18 +22,7 @@ return [
'lab.docs' => [
'pattern' => 'lab/docs',
'action' => function () {
$props = match (Docs::installed()) {
true => [
'categories' => [['examples' => Docs::all()]],
'tab' => 'docs',
],
false => [
'info' => 'The UI docs are not installed.',
'tab' => 'docs',
]
};
return [
$view = [
'component' => 'k-lab-index-view',
'title' => 'Docs',
'breadcrumb' => [
@ -40,8 +30,28 @@ return [
'label' => 'Docs',
'link' => 'lab/docs'
]
]
];
// if docs are not installed, show info message
if (Docs::isInstalled() === false) {
return [
...$view,
'props' => [
'info' => 'The UI docs are not installed.',
'tab' => 'docs',
],
];
}
return [
...$view,
'props' => [
'categories' => [
['examples' => Docs::all()]
],
'tab' => 'docs',
],
'props' => $props,
];
}
],
@ -59,7 +69,7 @@ return [
]
];
if (Docs::installed() === false) {
if (Docs::isInstalled() === false) {
return [
'component' => 'k-lab-index-view',
'title' => $component,
@ -71,16 +81,50 @@ return [
];
}
$docs = new Docs($component);
$doc = Doc::factory($component);
if ($doc === null) {
return [
'component' => 'k-lab-index-view',
'title' => $component,
'breadcrumb' => $crumbs,
'props' => [
'info' => 'No UI docs found for ' . $component . '.',
'tab' => 'docs',
],
];
}
// header buttons
$buttons = [];
if ($lab = $doc->lab()) {
$buttons[] = [
'props' => [
'text' => 'Lab examples',
'icon' => 'lab',
'link' => '/lab/' . $lab
]
];
}
$buttons[] = [
'props' => [
'icon' => 'github',
'link' => $doc->source(),
'target' => '_blank'
]
];
return [
'component' => 'k-lab-docs-view',
'title' => $component,
'breadcrumb' => $crumbs,
'props' => [
'buttons' => $buttons,
'component' => $component,
'docs' => $docs->toArray(),
'lab' => $docs->lab()
'docs' => $doc->toArray(),
'lab' => $lab
]
];
}
@ -111,16 +155,39 @@ return [
$vue = $example->vue();
$compiler = App::instance()->option('panel.vue.compiler', true);
if (Docs::installed() === true && $docs = $props['docs'] ?? null) {
$docs = new Docs($docs);
if ($doc = $props['docs'] ?? null) {
$doc = Doc::factory($doc);
}
$github = $docs?->github();
$github = $doc?->source();
if ($source = $props['source'] ?? null) {
$github ??= 'https://github.com/getkirby/kirby/tree/main/' . $source;
}
// header buttons
$buttons = [];
if ($doc) {
$buttons[] = [
'props' => [
'text' => $doc->name,
'icon' => 'book',
'drawer' => 'lab/docs/' . $doc->name
]
];
}
if ($github) {
$buttons[] = [
'props' => [
'icon' => 'github',
'link' => $github,
'target' => '_blank'
]
];
}
return [
'component' => 'k-lab-playground-view',
'breadcrumb' => [
@ -133,8 +200,9 @@ return [
]
],
'props' => [
'buttons' => $buttons,
'compiler' => $compiler,
'docs' => $docs?->name(),
'docs' => $doc?->name,
'examples' => $vue['examples'],
'file' => $example->module(),
'github' => $github,

View file

@ -7,6 +7,7 @@ return function ($kirby) {
'icon' => 'translate',
'label' => I18n::translate('view.languages'),
'menu' => true,
'buttons' => require __DIR__ . '/languages/buttons.php',
'dialogs' => require __DIR__ . '/languages/dialogs.php',
'views' => require __DIR__ . '/languages/views.php'
];

View file

@ -0,0 +1,21 @@
<?php
use Kirby\Cms\Language;
use Kirby\Panel\Ui\Buttons\LanguageCreateButton;
use Kirby\Panel\Ui\Buttons\LanguageDeleteButton;
use Kirby\Panel\Ui\Buttons\LanguageSettingsButton;
use Kirby\Panel\Ui\Buttons\OpenButton;
return [
'languages.create' => fn () =>
new LanguageCreateButton(),
'language.open' => fn (Language $language) =>
new OpenButton(link: $language->url()),
'language.settings' => fn (Language $language) =>
new LanguageSettingsButton($language),
'language.delete' => function (Language $language) {
if ($language->isDeletable() === true) {
return new LanguageDeleteButton($language);
}
}
];

View file

@ -2,6 +2,7 @@
use Kirby\Cms\App;
use Kirby\Cms\Find;
use Kirby\Cms\Language;
use Kirby\Cms\LanguageVariable;
use Kirby\Exception\NotFoundException;
use Kirby\Toolkit\A;
@ -49,16 +50,34 @@ $translationDialogFields = [
'label' => I18n::translate('language.variable.key'),
'type' => 'text'
],
'multiple' => [
'label' => I18n::translate('language.variable.multiple'),
'text' => I18n::translate('language.variable.multiple.text'),
'help' => I18n::translate('language.variable.multiple.help'),
'type' => 'toggle'
],
'value' => [
'buttons' => false,
'counter' => false,
'label' => I18n::translate('language.variable.value'),
'type' => 'textarea'
'type' => 'textarea',
'when' => [
'multiple' => false
]
],
'entries' => [
'field' => ['type' => 'text'],
'label' => I18n::translate('language.variable.entries'),
'help' => I18n::translate('language.variable.entries.help'),
'type' => 'entries',
'min' => 1,
'when' => [
'multiple' => true
],
]
];
return [
// create language
'language.create' => [
'pattern' => 'languages/create',
@ -184,6 +203,9 @@ return [
'props' => [
'fields' => $translationDialogFields,
'size' => 'large',
'value' => [
'multiple' => false,
]
],
];
},
@ -191,8 +213,13 @@ return [
$request = App::instance()->request();
$language = Find::language($languageCode);
$key = $request->get('key', '');
$value = $request->get('value', '');
$key = $request->get('key', '');
$multiple = $request->get('multiple', false);
$value = match ($multiple) {
true => $request->get('entries', []),
default => $request->get('value', '')
};
LanguageVariable::create($key, $value);
@ -209,9 +236,9 @@ return [
$variable = Find::language($languageCode)->variable($translationKey, true);
if ($variable->exists() === false) {
throw new NotFoundException([
'key' => 'language.variable.notFound'
]);
throw new NotFoundException(
key: 'language.variable.notFound'
);
}
return [
@ -230,48 +257,65 @@ return [
'language.translation.update' => [
'pattern' => 'languages/(:any)/translations/(:any)/update',
'load' => function (string $languageCode, string $translationKey) use ($translationDialogFields) {
$variable = Find::language($languageCode)->variable($translationKey, true);
$language = Find::language($languageCode);
$variable = $language->variable($translationKey, true);
if ($variable->exists() === false) {
throw new NotFoundException([
'key' => 'language.variable.notFound'
]);
throw new NotFoundException(
key: 'language.variable.notFound'
);
}
$fields = $translationDialogFields;
$fields['key']['disabled'] = true;
$fields['value']['autofocus'] = true;
// shows info text when variable is an array
// TODO: 5.0: use entries field instead showing info text
$isVariableArray = is_array($variable->value()) === true;
// the key field cannot be changed
// the multiple field is hidden
$fields['key']['disabled'] = true;
$fields['multiple']['type'] = 'hidden';
// check if the variable has multiple values;
// ensure to use the default language for this check because
// the variable might not exist in the current language but
// already be defined in the default language with multiple values
$isVariableArray = Language::ensure('default')->variable($translationKey, true)->hasMultipleValues();
// set the correct value field
// when value is string, set value for value field
// when value is array, set value for entries field
if ($isVariableArray === true) {
$fields['value'] = [
'label' => I18n::translate('info'),
'type' => 'info',
'text' => 'You are using an array variable for this key. Please modify it in the language file in /site/languages',
$fields['entries']['autofocus'] = true;
$value = [
'entries' => $variable->value(),
'key' => $variable->key(),
'multiple' => true
];
} else {
$fields['value']['autofocus'] = true;
$value = [
'key' => $variable->key(),
'multiple' => false,
'value' => $variable->value()
];
}
return [
'component' => 'k-form-dialog',
'props' => [
'cancelButton' => $isVariableArray === false,
'fields' => $fields,
'size' => 'large',
'submitButton' => $isVariableArray === false,
'value' => [
'key' => $variable->key(),
'value' => $variable->value()
]
],
'fields' => $fields,
'size' => 'large',
'value' => $value
]
];
},
'submit' => function (string $languageCode, string $translationKey) {
Find::language($languageCode)->variable($translationKey, true)->update(
App::instance()->request()->get('value', '')
);
$request = App::instance()->request();
$multiple = $request->get('multiple', false);
$value = match ($multiple) {
true => $request->get('entries', []),
default => $request->get('value', '')
};
Find::language($languageCode)->variable($translationKey, true)->update($value);
return true;
}

View file

@ -2,6 +2,7 @@
use Kirby\Cms\App;
use Kirby\Cms\Find;
use Kirby\Panel\Ui\Buttons\ViewButtons;
use Kirby\Toolkit\Escape;
use Kirby\Toolkit\I18n;
@ -19,9 +20,9 @@ return [
$foundation = $kirby->defaultLanguage()->translations();
$translations = $language->translations();
// TODO: update following line and adapt for update and delete options
// when new `languageVariables.*` permissions available
$canUpdate = $kirby->user()?->role()->permissions()->for('languages', 'update') === true;
// TODO: update following line and adapt for update and
// delete options when `languageVariables.*` permissions available
$canUpdate = $kirby->role()?->permissions()->for('languages', 'update') === true;
ksort($foundation);
@ -73,6 +74,10 @@ return [
]
],
'props' => [
'buttons' => fn () =>
ViewButtons::view('language', model: $language)
->defaults('open', 'settings', 'delete')
->render(),
'deletable' => $language->isDeletable(),
'code' => Escape::html($language->code()),
'default' => $language->isDefault(),
@ -113,6 +118,10 @@ return [
return [
'component' => 'k-languages-view',
'props' => [
'buttons' => fn () =>
ViewButtons::view('languages')
->defaults('create')
->render(),
'languages' => $kirby->languages()->values(fn ($language) => [
'deletable' => $language->isDeletable(),
'default' => $language->isDefault(),

View file

@ -12,6 +12,7 @@ return function ($kirby) {
'icon' => $blueprint->icon() ?? 'home',
'label' => $blueprint->title() ?? I18n::translate('view.site'),
'menu' => true,
'buttons' => require __DIR__ . '/site/buttons.php',
'dialogs' => require __DIR__ . '/site/dialogs.php',
'drawers' => require __DIR__ . '/site/drawers.php',
'dropdowns' => require __DIR__ . '/site/dropdowns.php',

View file

@ -0,0 +1,72 @@
<?php
use Kirby\Cms\ModelWithContent;
use Kirby\Cms\Page;
use Kirby\Cms\Site;
use Kirby\Panel\Ui\Buttons\LanguagesDropdown;
use Kirby\Panel\Ui\Buttons\OpenButton;
use Kirby\Panel\Ui\Buttons\PageStatusButton;
use Kirby\Panel\Ui\Buttons\PreviewButton;
use Kirby\Panel\Ui\Buttons\SettingsButton;
use Kirby\Panel\Ui\Buttons\VersionsButton;
return [
'site.open' => function (Site $site, string $versionId = 'latest') {
$versionId = $versionId === 'compare' ? 'changes' : $versionId;
$link = $site->previewUrl($versionId);
if ($link !== null) {
return new OpenButton(
link: $link,
);
}
},
'site.preview' => function (Site $site) {
if ($site->previewUrl() !== null) {
return new PreviewButton(
link: $site->panel()->url(true) . '/preview/changes',
);
}
},
'site.versions' => function (Site $site, string $versionId = 'latest') {
return new VersionsButton(
model: $site,
versionId: $versionId
);
},
'page.open' => function (Page $page, string $versionId = 'latest') {
$versionId = $versionId === 'compare' ? 'changes' : $versionId;
$link = $page->previewUrl($versionId);
if ($link !== null) {
return new OpenButton(
link: $link,
);
}
},
'page.preview' => function (Page $page) {
if ($page->previewUrl() !== null) {
return new PreviewButton(
link: $page->panel()->url(true) . '/preview/changes',
);
}
},
'page.versions' => function (Page $page, string $versionId = 'latest') {
return new VersionsButton(
model: $page,
versionId: $versionId
);
},
'page.settings' => fn (Page $page) => new SettingsButton(model: $page),
'page.status' => fn (Page $page) => new PageStatusButton($page),
// `languages` button needs to be in site area,
// as the languages might be not loaded even in
// multilang mode when the `languages` option is deactivated
// (but content languages to switch between still can exist)
'languages' => fn (ModelWithContent $model) =>
new LanguagesDropdown($model),
// file buttons
...require __DIR__ . '/../files/buttons.php'
];

View file

@ -28,12 +28,10 @@ return [
$page = Find::page($id);
if ($page->blueprint()->num() !== 'default') {
throw new PermissionException([
'key' => 'page.sort.permission',
'data' => [
'slug' => $page->slug()
]
]);
throw new PermissionException(
key: 'page.sort.permission',
data: ['slug' => $page->slug()]
);
}
return [
@ -150,12 +148,10 @@ return [
$blueprints = $page->blueprints();
if (count($blueprints) <= 1) {
throw new Exception([
'key' => 'page.changeTemplate.invalid',
'data' => [
'slug' => $id
]
]);
throw new Exception(
key: 'page.changeTemplate.invalid',
data: ['slug' => $id]
);
}
return [
@ -264,20 +260,17 @@ return [
// the page title changed
if ($page->title()->value() !== $title) {
$page->changeTitle($title);
$page = $page->changeTitle($title);
$response['event'][] = 'page.changeTitle';
}
// the slug changed
if ($page->slug() !== $slug) {
$newPage = $page->changeSlug($slug);
$response['event'][] = 'page.changeSlug';
$response['dispatch'] = [
'content/move' => [
$oldUrl = $page->panel()->url(true),
$newUrl = $newPage->panel()->url(true)
]
];
$newPage = $page->changeSlug($slug);
$oldUrl = $page->panel()->url(true);
$newUrl = $newPage->panel()->url(true);
// check for a necessary redirect after the slug has changed
if (Panel::referrer() === $oldUrl && $oldUrl !== $newUrl) {
@ -372,7 +365,9 @@ return [
$page->childrenAndDrafts()->count() > 0 &&
$request->get('check') !== $page->title()->value()
) {
throw new InvalidArgumentException(['key' => 'page.delete.confirm']);
throw new InvalidArgumentException(
key: 'page.delete.confirm'
);
}
$page->delete(true);
@ -385,7 +380,6 @@ return [
return [
'event' => 'page.delete',
'dispatch' => ['content/remove' => [$url]],
'redirect' => $redirect
];
}
@ -416,19 +410,17 @@ return [
if ($hasFiles === true) {
$fields['files'] = [
'label' => I18n::translate('page.duplicate.files'),
'type' => 'toggle',
'required' => true,
'width' => $toggleWidth
'label' => I18n::translate('page.duplicate.files'),
'type' => 'toggle',
'width' => $toggleWidth
];
}
if ($hasChildren === true) {
$fields['children'] = [
'label' => I18n::translate('page.duplicate.pages'),
'type' => 'toggle',
'required' => true,
'width' => $toggleWidth
'label' => I18n::translate('page.duplicate.pages'),
'type' => 'toggle',
'width' => $toggleWidth
];
}
@ -440,11 +432,11 @@ return [
$duplicateSlug = $page->slug() . '-' . $slugAppendix;
$siblingKeys = $page->parentModel()->childrenAndDrafts()->pluck('uid');
if (in_array($duplicateSlug, $siblingKeys) === true) {
if (in_array($duplicateSlug, $siblingKeys, true) === true) {
$suffixCounter = 2;
$newSlug = $duplicateSlug . $suffixCounter;
while (in_array($newSlug, $siblingKeys) === true) {
while (in_array($newSlug, $siblingKeys, true) === true) {
$newSlug = $duplicateSlug . ++$suffixCounter;
}
@ -556,13 +548,7 @@ return [
return [
'event' => 'page.move',
'redirect' => $newPage->panel()->url(true),
'dispatch' => [
'content/move' => [
$oldPage->panel()->url(true),
$newPage->panel()->url(true)
]
],
'redirect' => $newPage->panel()->url(true)
];
}
],
@ -643,13 +629,7 @@ return [
'changes' => [
'pattern' => 'changes',
'load' => function () {
$dialog = new ChangesDialog();
return $dialog->load();
return (new ChangesDialog())->load();
},
'submit' => function () {
$dialog = new ChangesDialog();
$ids = App::instance()->request()->get('ids');
return $dialog->submit($ids);
}
],
];

View file

@ -1,5 +1,8 @@
<?php
use Kirby\Cms\App;
use Kirby\Cms\Find;
use Kirby\Panel\Ui\Buttons\LanguagesDropdown;
$files = require __DIR__ . '/../files/dropdowns.php';
@ -10,12 +13,34 @@ return [
return Find::page($path)->panel()->dropdown();
}
],
'page.languages' => [
'pattern' => 'pages/(:any)/languages',
'options' => function (string $path) {
$page = Find::page($path);
return (new LanguagesDropdown($page))->options();
}
],
'page.file' => [
'pattern' => '(pages/.*?)/files/(:any)',
'options' => $files['file']
],
'page.file.languages' => [
'pattern' => '(pages/.*?)/files/(:any)/languages',
'options' => $files['language']
],
'site.languages' => [
'pattern' => 'site/languages',
'options' => function () {
$site = App::instance()->site();
return (new LanguagesDropdown($site))->options();
}
],
'site.file' => [
'pattern' => '(site)/files/(:any)',
'options' => $files['file']
],
'site.file.languages' => [
'pattern' => '(site)/files/(:any)/languages',
'options' => $files['language']
]
];

View file

@ -1,90 +1,25 @@
<?php
use Kirby\Cms\App;
use Kirby\Cms\Find;
use Kirby\Toolkit\I18n;
use Kirby\Panel\Controller\PageTree;
return [
// @codeCoverageIgnoreStart
// TODO: move to controller class and add unit tests
'tree' => [
'pattern' => 'site/tree',
'action' => function () {
$kirby = App::instance();
$request = $kirby->request();
$move = $request->get('move');
$move = $move ? Find::parent($move) : null;
$parent = $request->get('parent');
if ($parent === null) {
$site = $kirby->site();
$panel = $site->panel();
$uuid = $site->uuid()?->toString();
$url = $site->url();
$value = $uuid ?? '/';
return [
[
'children' => $panel->url(true),
'disabled' => $move?->isMovableTo($site) === false,
'hasChildren' => true,
'icon' => 'home',
'id' => '/',
'label' => I18n::translate('view.site'),
'open' => false,
'url' => $url,
'uuid' => $uuid,
'value' => $value
]
];
}
$parent = Find::parent($parent);
$pages = [];
foreach ($parent->childrenAndDrafts()->filterBy('isListable', true) as $child) {
$panel = $child->panel();
$uuid = $child->uuid()?->toString();
$url = $child->url();
$value = $uuid ?? $child->id();
$pages[] = [
'children' => $panel->url(true),
'disabled' => $move?->isMovableTo($child) === false,
'hasChildren' => $child->hasChildren() === true || $child->hasDrafts() === true,
'icon' => $panel->image()['icon'] ?? null,
'id' => $child->id(),
'open' => false,
'label' => $child->title()->value(),
'url' => $url,
'uuid' => $uuid,
'value' => $value
];
}
return $pages;
return (new PageTree())->children(
parent: App::instance()->request()->get('parent'),
moving: App::instance()->request()->get('move')
);
}
],
'tree.parents' => [
'pattern' => 'site/tree/parents',
'action' => function () {
$kirby = App::instance();
$request = $kirby->request();
$root = $request->get('root');
$page = $kirby->page($request->get('page'));
$parents = $page?->parents()->flip()->values(
fn ($parent) => $parent->uuid()?->toString() ?? $parent->id()
) ?? [];
// if root is included, add the site as top-level parent
if ($root === 'true') {
array_unshift($parents, $kirby->site()->uuid()?->toString() ?? '/');
}
return [
'data' => $parents
];
return (new PageTree())->parents(
page: App::instance()->request()->get('page'),
includeSite: App::instance()->request()->get('root') === 'true',
);
}
]
// @codeCoverageIgnoreEnd
];

View file

@ -1,56 +1,17 @@
<?php
use Kirby\Cms\App;
use Kirby\Toolkit\Escape;
use Kirby\Panel\Controller\Search;
use Kirby\Toolkit\I18n;
return [
'pages' => [
'label' => I18n::translate('pages'),
'icon' => 'page',
'query' => function (string|null $query, int $limit, int $page) {
$kirby = App::instance();
$pages = $kirby->site()
->index(true)
->search($query)
->filter('isListable', true)
->paginate($limit, $page);
return [
'results' => $pages->values(fn ($page) => [
'image' => $page->panel()->image(),
'text' => Escape::html($page->title()->value()),
'link' => $page->panel()->url(true),
'info' => Escape::html($page->id()),
'uuid' => $page->uuid()?->toString(),
]),
'pagination' => $pages->pagination()->toArray()
];
}
'query' => fn (string|null $query, int $limit, int $page) => Search::pages($query, $limit, $page)
],
'files' => [
'label' => I18n::translate('files'),
'icon' => 'image',
'query' => function (string|null $query, int $limit, int $page) {
$kirby = App::instance();
$files = $kirby->site()
->index(true)
->filter('isListable', true)
->files()
->filter('isListable', true)
->search($query)
->paginate($limit, $page);
return [
'results' => $files->values(fn ($file) => [
'image' => $file->panel()->image(),
'text' => Escape::html($file->filename()),
'link' => $file->panel()->url(true),
'info' => Escape::html($file->id()),
'uuid' => $file->uuid()->toString(),
]),
'pagination' => $files->pagination()->toArray()
];
}
'query' => fn (string|null $query, int $limit, int $page) => Search::files($query, $limit, $page)
]
];

View file

@ -2,6 +2,9 @@
use Kirby\Cms\App;
use Kirby\Cms\Find;
use Kirby\Exception\PermissionException;
use Kirby\Panel\Ui\Buttons\ViewButtons;
use Kirby\Toolkit\I18n;
return [
'page' => [
@ -14,6 +17,40 @@ return [
return Find::file('pages/' . $id, $filename)->panel()->view();
}
],
'page.preview' => [
'pattern' => 'pages/(:any)/preview/(changes|latest|compare)',
'action' => function (string $path, string $versionId) {
$page = Find::page($path);
$view = $page->panel()->view();
$src = [
'latest' => $page->previewUrl('latest'),
'changes' => $page->previewUrl('changes'),
];
if ($src['latest'] === null) {
throw new PermissionException('The preview is not available');
}
return [
'component' => 'k-preview-view',
'props' => [
...$view['props'],
'back' => $view['props']['link'],
'buttons' => fn () =>
ViewButtons::view('page.preview', model: $page)
->defaults(
'page.versions',
'languages',
)
->bind(['versionId' => $versionId])
->render(),
'src' => $src,
'versionId' => $versionId,
],
'title' => $view['props']['title'] . ' | ' . I18n::translate('preview'),
];
}
],
'site' => [
'pattern' => 'site',
'action' => fn () => App::instance()->site()->panel()->view()
@ -24,4 +61,38 @@ return [
return Find::file('site', $filename)->panel()->view();
}
],
'site.preview' => [
'pattern' => 'site/preview/(changes|latest|compare)',
'action' => function (string $versionId) {
$site = App::instance()->site();
$view = $site->panel()->view();
$src = [
'latest' => $site->previewUrl('latest'),
'changes' => $site->previewUrl('changes'),
];
if ($src['latest'] === null) {
throw new PermissionException('The preview is not available');
}
return [
'component' => 'k-preview-view',
'props' => [
...$view['props'],
'back' => $view['props']['link'],
'buttons' => fn () =>
ViewButtons::view('site.preview', model: $site)
->defaults(
'site.versions',
'languages'
)
->bind(['versionId' => $versionId])
->render(),
'src' => $src,
'versionId' => $versionId
],
'title' => I18n::translate('view.site') . ' | ' . I18n::translate('preview'),
];
}
],
];

View file

@ -53,7 +53,7 @@ return [
];
}
throw new LogicException('The upgrade failed');
throw new LogicException(message: 'The upgrade failed');
// @codeCoverageIgnoreEnd
}
],

View file

@ -1,6 +1,7 @@
<?php
use Kirby\Cms\App;
use Kirby\Panel\Ui\Buttons\ViewButtons;
use Kirby\Toolkit\I18n;
return [
@ -59,11 +60,12 @@ return [
return [
'author' => empty($authors) ? '' : $authors,
'license' => $plugin->license() ?? '',
'license' => $plugin->license()->toArray(),
'name' => [
'text' => $plugin->name() ?? '',
'href' => $plugin->link(),
],
'status' => $plugin->license()->status()->toArray(),
'version' => $version,
];
});
@ -122,12 +124,14 @@ return [
return [
'component' => 'k-system-view',
'props' => [
'buttons' => fn () =>
ViewButtons::view('system')->render(),
'environment' => $environment,
'exceptions' => $debugMode ? $exceptions : [],
'info' => $system->info(),
'plugins' => $plugins,
'security' => $security,
'urls' => $sensitive ?? null
'urls' => $sensitive ?? []
]
];
}

View file

@ -8,6 +8,7 @@ return function ($kirby) {
'label' => I18n::translate('view.users'),
'search' => 'users',
'menu' => true,
'buttons' => require __DIR__ . '/users/buttons.php',
'dialogs' => require __DIR__ . '/users/dialogs.php',
'drawers' => require __DIR__ . '/users/drawers.php',
'dropdowns' => require __DIR__ . '/users/dropdowns.php',

View file

@ -0,0 +1,20 @@
<?php
use Kirby\Cms\User;
use Kirby\Panel\Ui\Buttons\SettingsButton;
use Kirby\Panel\Ui\Buttons\ViewButton;
use Kirby\Toolkit\I18n;
return [
'users.create' => function (User $user, string|null $role = null) {
return new ViewButton(
dialog: 'users/create?role=' . $role,
disabled: $user->kirby()->roles()->canBeCreated()->count() < 1,
icon: 'add',
text: I18n::translate('user.create'),
);
},
'user.settings' => function (User $user) {
return new SettingsButton(model: $user);
}
];

View file

@ -57,7 +57,7 @@ return [
'email' => '',
'password' => '',
'translation' => $kirby->panelLanguage(),
'role' => $role ?? $roles['options'][0]['value'] ?? null
'role' => $role ?: $roles['options'][0]['value'] ?? null
]
]
];
@ -231,9 +231,9 @@ return [
// compare passwords
if ($password !== $passwordConfirmation) {
throw new InvalidArgumentException([
'key' => 'user.password.notSame'
]);
throw new InvalidArgumentException(
key: 'user.password.notSame'
);
}
// change password if everything's fine
@ -319,7 +319,6 @@ return [
return [
'event' => 'user.delete',
'dispatch' => ['content/remove' => [$url]],
'redirect' => $redirect
];
}

View file

@ -1,18 +1,29 @@
<?php
use Kirby\Cms\Find;
use Kirby\Panel\Ui\Buttons\LanguagesDropdown;
$files = require __DIR__ . '/../files/dropdowns.php';
return [
'user' => [
'pattern' => 'users/(:any)',
'options' => fn (string $id) =>
Find::user($id)->panel()->dropdown()
],
'user.languages' => [
'pattern' => 'users/(:any)/languages',
'options' => function (string $id) {
return Find::user($id)->panel()->dropdown();
$user = Find::user($id);
return (new LanguagesDropdown($user))->options();
}
],
'user.file' => [
'pattern' => '(users/.*?)/files/(:any)',
'options' => $files['file']
],
'user.file.languages' => [
'pattern' => '(users/.*?)/files/(:any)/languages',
'options' => $files['language']
]
];

View file

@ -1,29 +1,12 @@
<?php
use Kirby\Cms\App;
use Kirby\Toolkit\Escape;
use Kirby\Panel\Controller\Search;
use Kirby\Toolkit\I18n;
return [
'users' => [
'label' => I18n::translate('users'),
'icon' => 'users',
'query' => function (string|null $query, int $limit, int $page) {
$kirby = App::instance();
$users = $kirby->users()
->search($query)
->paginate($limit, $page);
return [
'results' => $users->values(fn ($user) => [
'image' => $user->panel()->image(),
'text' => Escape::html($user->username()),
'link' => $user->panel()->url(true),
'info' => Escape::html($user->role()->title()),
'uuid' => $user->uuid()->toString(),
]),
'pagination' => $users->pagination()->toArray()
];
}
'query' => fn (string|null $query, int $limit, int $page) => Search::users($query, $limit, $page)
]
];

View file

@ -2,6 +2,7 @@
use Kirby\Cms\App;
use Kirby\Cms\Find;
use Kirby\Panel\Ui\Buttons\ViewButtons;
use Kirby\Toolkit\Escape;
return [
@ -18,7 +19,11 @@ return [
return [
'component' => 'k-users-view',
'props' => [
'canCreate' => $kirby->roles()->canBeCreated()->count() > 0,
'buttons' => fn () =>
ViewButtons::view('users')
->defaults('create')
->bind(['role' => $role])
->render(),
'role' => function () use ($roles, $role) {
if ($role) {
return $roles[$role] ?? null;

View file

@ -4,11 +4,15 @@ use Kirby\Cms\App;
use Kirby\Cms\Collection;
use Kirby\Cms\File;
use Kirby\Cms\FileVersion;
use Kirby\Cms\ModelWithContent;
use Kirby\Cms\Page;
use Kirby\Cms\User;
use Kirby\Content\PlainTextStorage;
use Kirby\Content\Storage;
use Kirby\Data\Data;
use Kirby\Email\PHPMailer as Emailer;
use Kirby\Exception\NotFoundException;
use Kirby\Filesystem\Asset;
use Kirby\Filesystem\F;
use Kirby\Filesystem\Filename;
use Kirby\Http\Uri;
@ -59,22 +63,20 @@ return [
/**
* Adapt file characteristics
*
* @param \Kirby\Cms\File|\Kirby\Filesystem\Asset $file The file object
* @param array $options All thumb options (width, height, crop, blur, grayscale)
* @return \Kirby\Cms\File|\Kirby\Cms\FileVersion|\Kirby\Filesystem\Asset
*/
'file::version' => function (
App $kirby,
$file,
File|Asset $file,
array $options = []
) {
): File|Asset|FileVersion {
// if file is not resizable, return
if ($file->isResizable() === false) {
return $file;
}
// create url and root
$mediaRoot = dirname($file->mediaRoot());
$mediaRoot = $file->mediaDir();
$template = $mediaRoot . '/{{ name }}{{ attributes }}.{{ extension }}';
$thumbRoot = (new Filename($file->root(), $template, $options))->toString();
$thumbName = basename($thumbRoot);
@ -85,9 +87,10 @@ return [
$job = $mediaRoot . '/.jobs/' . $thumbName . '.json';
try {
Data::write($job, array_merge($options, [
'filename' => $file->filename()
]));
Data::write(
$job,
[...$options, 'filename' => $file->filename()]
);
} catch (Throwable) {
// if thumb doesn't exist yet and job file cannot
// be created, return
@ -99,7 +102,7 @@ return [
'modifications' => $options,
'original' => $file,
'root' => $thumbRoot,
'url' => dirname($file->mediaUrl()) . '/' . $thumbName,
'url' => $file->mediaUrl($thumbName),
]);
},
@ -150,17 +153,16 @@ return [
$params = ['fields' => Str::split($params, '|')];
}
$defaults = [
$collection = clone $collection;
$query = trim($query ?? '');
$options = [
'fields' => [],
'minlength' => 2,
'score' => [],
'words' => false,
...$params
];
$collection = clone $collection;
$options = array_merge($defaults, $params);
$query = trim($query ?? '');
// empty or too short search query
if (Str::length($query) < $options['minlength']) {
return $collection->limit(0);
@ -204,10 +206,11 @@ return [
$keys[] = 'role';
} elseif ($item instanceof Page) {
// apply the default score for pages
$options['score'] = array_merge(
['id' => 64, 'title' => 64],
$options['score']
);
$options['score'] = [
'id' => 64,
'title' => 64,
...$options['score']
];
}
if (empty($options['fields']) === false) {
@ -231,7 +234,7 @@ return [
$scoring['score'] += 16 * $score;
$scoring['hits'] += 1;
// check for exact beginning matches
// check for exact beginning matches
} elseif (
$options['words'] === false &&
Str::startsWith($lowerValue, $query) === true
@ -239,7 +242,7 @@ return [
$scoring['score'] += 8 * $score;
$scoring['hits'] += 1;
// check for exact query matches
// check for exact query matches
} elseif ($matches = preg_match_all('!' . $exact . '!ui', $value, $r)) {
$scoring['score'] += 2 * $score;
$scoring['hits'] += $matches;
@ -309,6 +312,16 @@ return [
return Snippet::factory($name, $data, $slots);
},
/**
* Create a new storage object for the given model
*/
'storage' => function (
App $kirby,
ModelWithContent $model
): Storage {
return new PlainTextStorage(model: $model);
},
/**
* Add your own template engine
*
@ -332,7 +345,6 @@ return [
* @param string $src Root of the original file
* @param string $dst Template string for the root to the desired destination
* @param array $options All thumb options that should be applied: `width`, `height`, `crop`, `blur`, `grayscale`
* @return string
*/
'thumb' => function (
App $kirby,
@ -401,7 +413,7 @@ return [
// keep relative urls
if (
$path !== null &&
(substr($path, 0, 2) === './' || substr($path, 0, 3) === '../')
(str_starts_with($path, './') || str_starts_with($path, '../'))
) {
return $path;
}
@ -417,7 +429,9 @@ return [
$model = Uuid::for($path)->model();
if ($model === null) {
throw new NotFoundException('The model could not be found for "' . $path . '" uuid');
throw new NotFoundException(
message: 'The model could not be found for "' . $path . '" uuid'
);
}
$path = $model->url();

View file

@ -1,5 +1,6 @@
<?php
use Kirby\Cms\Helpers;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Field\FieldOptions;
use Kirby\Toolkit\A;
@ -24,8 +25,10 @@ return [
* The CSS format (hex, rgb, hsl) to display and store the value
*/
'format' => function (string $format = 'hex'): string {
if (in_array($format, ['hex', 'hsl', 'rgb']) === false) {
throw new InvalidArgumentException('Unsupported format for color field (supported: hex, rgb, hsl)');
if (in_array($format, ['hex', 'hsl', 'rgb'], true) === false) {
throw new InvalidArgumentException(
message: 'Unsupported format for color field (supported: hex, rgb, hsl)'
);
}
return $format;
@ -35,8 +38,10 @@ return [
* show the `options` as toggles
*/
'mode' => function (string $mode = 'picker'): string {
if (in_array($mode, ['picker', 'input', 'options']) === false) {
throw new InvalidArgumentException('Unsupported mode for color field (supported: picker, input, options)');
if (in_array($mode, ['picker', 'input', 'options'], true) === false) {
throw new InvalidArgumentException(
message: 'Unsupported mode for color field (supported: picker, input, options)'
);
}
return $mode;
@ -69,30 +74,33 @@ return [
return [];
}
$options = match (true) {
// simple array of values
// or value=text (from Options class)
if (
is_numeric($options[0]['value']) ||
$options[0]['value'] === $options[0]['text']
=> A::map($options, fn ($option) => [
'value' => $option['text']
]),
) {
// simple array of values
// or value=text (from Options class)
$options = A::map($options, fn ($option) => [
'value' => $option['text']
]);
// deprecated: name => value, flipping
// TODO: start throwing in warning in v5
$this->isColor($options[0]['text'])
=> A::map($options, fn ($option) => [
'value' => $option['text'],
// ensure that any HTML in the new text is escaped
'text' => Escape::html($option['value'])
]),
} elseif ($this->isColor($options[0]['text'])) {
// @deprecated 4.0.0
// TODO: Remove in Kirby 6
default
=> A::map($options, fn ($option) => [
Helpers::deprecated('Color field "' . $this->name . '": the text => value notation for options has been deprecated and will be removed in Kirby 6. Please rewrite your options as value => text.');
$options = A::map($options, fn ($option) => [
'value' => $option['text'],
// ensure that any HTML in the new text is escaped
'text' => Escape::html($option['value'])
]);
} else {
$options = A::map($options, fn ($option) => [
'value' => $option['value'],
'text' => $option['text']
]),
};
]);
}
return $options;
}
@ -121,24 +129,24 @@ return [
}
if ($this->format === 'hex' && $this->isHex($value) === false) {
throw new InvalidArgumentException([
'key' => 'validation.color',
'data' => ['format' => 'hex']
]);
throw new InvalidArgumentException(
key: 'validation.color',
data: ['format' => 'hex']
);
}
if ($this->format === 'rgb' && $this->isRgb($value) === false) {
throw new InvalidArgumentException([
'key' => 'validation.color',
'data' => ['format' => 'rgb']
]);
throw new InvalidArgumentException(
key: 'validation.color',
data: ['format' => 'rgb']
);
}
if ($this->format === 'hsl' && $this->isHsl($value) === false) {
throw new InvalidArgumentException([
'key' => 'validation.color',
'data' => ['format' => 'hsl']
]);
throw new InvalidArgumentException(
key: 'validation.color',
data: ['format' => 'hsl']
);
}
}
]

View file

@ -125,27 +125,27 @@ return [
$format = $this->time === false ? 'd.m.Y' : 'd.m.Y H:i';
if ($min && $max && $value->isBetween($min, $max) === false) {
throw new Exception([
'key' => 'validation.date.between',
'data' => [
throw new Exception(
key: 'validation.date.between',
data: [
'min' => $min->format($format),
'max' => $max->format($format)
]
]);
} elseif ($min && $value->isMin($min) === false) {
throw new Exception([
'key' => 'validation.date.after',
'data' => [
'date' => $min->format($format),
]
]);
} elseif ($max && $value->isMax($max) === false) {
throw new Exception([
'key' => 'validation.date.before',
'data' => [
'date' => $max->format($format),
]
]);
);
}
if ($min && $value->isMin($min) === false) {
throw new Exception(
key: 'validation.date.after',
data: ['date' => $min->format($format)]
);
}
if ($max && $value->isMax($max) === false) {
throw new Exception(
key: 'validation.date.before',
data: ['date' => $max->format($format)]
);
}
return true;

View file

@ -48,7 +48,7 @@ return [
'activeTypes' => function () {
return array_filter(
$this->availableTypes(),
fn (string $type) => in_array($type, $this->props['options']),
fn (string $type) => in_array($type, $this->props['options'], true),
ARRAY_FILTER_USE_KEY
);
},
@ -153,17 +153,17 @@ return [
$detected = true;
if ($options['validate']($link) === false) {
throw new InvalidArgumentException([
'key' => 'validation.' . $type
]);
throw new InvalidArgumentException(
key: 'validation.' . $type
);
}
}
// none of the configured types has been detected
if ($detected === false) {
throw new InvalidArgumentException([
'key' => 'validation.linkType'
]);
throw new InvalidArgumentException(
key: 'validation.linkType'
);
}
return true;

View file

@ -7,8 +7,11 @@ return [
* Available layouts: `list`, `cardlets`, `cards`
*/
'layout' => function (string $layout = 'list') {
$layouts = ['list', 'cardlets', 'cards'];
return in_array($layout, $layouts) ? $layout : 'list';
return match ($layout) {
'cards' => 'cards',
'cardlets' => 'cardlets',
default => 'list'
};
},
/**

View file

@ -36,7 +36,7 @@ return [
},
'sanitizeOption' => function ($value) {
$options = array_column($this->options(), 'value');
return in_array($value, $options) === true ? $value : null;
return in_array($value, $options) ? $value : null;
},
'sanitizeOptions' => function ($values) {
$options = array_column($this->options(), 'value');

View file

@ -34,7 +34,9 @@ return [
$parent = $this->uploadParent($uploads['parent'] ?? null);
if ($parent === null) {
throw new InvalidArgumentException('"' . $uploads['parent'] . '" could not be resolved as a valid parent for the upload');
throw new InvalidArgumentException(
message: '"' . $uploads['parent'] . '" could not be resolved as a valid parent for the upload'
);
}
$file = new File([
@ -52,7 +54,9 @@ return [
'methods' => [
'upload' => function (Api $api, $params, Closure $map) {
if ($params === false) {
throw new Exception('Uploads are disabled for this field');
throw new Exception(
message: 'Uploads are disabled for this field'
);
}
$parent = $this->uploadParent($params['parent'] ?? null);
@ -68,7 +72,9 @@ return [
$file = $parent->createFile($props, true);
if ($file instanceof File === false) {
throw new Exception('The file could not be uploaded');
throw new Exception(
message: 'The file could not be uploaded'
);
}
return $map($file, $parent);

View file

@ -38,7 +38,7 @@ return [
],
'methods' => [
'toNumber' => function ($value): float|null {
if ($this->isEmpty($value) === true) {
if ($this->isEmptyValue($value) === true) {
return null;
}

View file

@ -91,13 +91,13 @@ return [
$name = array_key_first($errors);
$error = $errors[$name];
throw new InvalidArgumentException([
'key' => 'object.validation',
'data' => [
throw new InvalidArgumentException(
key: 'object.validation',
data: [
'label' => $error['label'] ?? $name,
'message' => implode("\n", $error['message'])
]
]);
);
}
}
]

View file

@ -20,7 +20,8 @@ return [
],
'computed' => [
'default' => function () {
return $this->sanitizeOption($this->default);
$default = $this->model()->toString($this->default);
return $this->sanitizeOption($default);
},
'value' => function () {
return $this->sanitizeOption($this->value) ?? '';

View file

@ -18,7 +18,7 @@ return [
return $icon;
},
/**
* Custom placeholder string for empty option.
* Text shown when no option is selected yet
*/
'placeholder' => function (string|array $placeholder = '—') {
return I18n::translate($placeholder, $placeholder);

View file

@ -149,7 +149,7 @@ return [
// make the first column visible on mobile
// if no other mobile columns are defined
if (in_array(true, array_column($columns, 'mobile')) === false) {
if (in_array(true, array_column($columns, 'mobile'), true) === false) {
$columns[array_key_first($columns)]['mobile'] = true;
}
@ -166,24 +166,37 @@ return [
continue;
}
$value[] = $this->form($row)->values();
$value[] = $this->form()->fill(input: $row, passthrough: true)->toFormValues();
}
return $value;
},
'form' => function (array $values = []) {
return new Form([
'fields' => $this->attrs['fields'] ?? [],
'values' => $values,
'model' => $this->model
]);
},
'form' => function () {
$this->form ??= new Form(
fields: $this->attrs['fields'] ?? [],
model: $this->model,
language: 'current'
);
return $this->form->reset();
}
],
'save' => function ($value) {
$data = [];
$data = [];
$form = $this->form();
$defaults = $form->defaults();
foreach ($value as $row) {
$row = $this->form($row)->content();
foreach ($value as $index => $row) {
$row = $form
->reset()
->fill(
input: $defaults,
)
->submit(
input: $row,
passthrough: true
)
->toStoredValues();
// remove frontend helper id
unset($row['_id']);
@ -204,19 +217,20 @@ return [
$values = A::wrap($value);
foreach ($values as $index => $value) {
$form = $this->form($value);
$form = $this->form();
$form->fill(input: $value);
foreach ($form->fields() as $field) {
$errors = $field->errors();
if (empty($errors) === false) {
throw new InvalidArgumentException([
'key' => 'structure.validation',
'data' => [
throw new InvalidArgumentException(
key: 'structure.validation',
data: [
'field' => $field->label() ?? Str::ucfirst($field->name()),
'index' => $index + 1
]
]);
);
}
}
}

View file

@ -10,11 +10,14 @@ return [
* The field value will be converted with the selected converter before the value gets saved. Available converters: `lower`, `upper`, `ucfirst`, `slug`
*/
'converter' => function ($value = null) {
if ($value !== null && array_key_exists($value, $this->converters()) === false) {
throw new InvalidArgumentException([
'key' => 'field.converter.invalid',
'data' => ['converter' => $value]
]);
if (
$value !== null &&
array_key_exists($value, $this->converters()) === false
) {
throw new InvalidArgumentException(
key: 'field.converter.invalid',
data: ['converter' => $value]
);
}
return $value;

View file

@ -89,12 +89,11 @@ return [
[
'pattern' => 'files',
'action' => function () {
$params = array_merge($this->field()->files(), [
return $this->field()->filepicker([
...$this->field()->files(),
'page' => $this->requestQuery('page'),
'search' => $this->requestQuery('search')
]);
return $this->field()->filepicker($params);
}
],
[
@ -104,14 +103,12 @@ return [
$field = $this->field();
$uploads = $field->uploads();
return $this->field()->upload($this, $uploads, function ($file, $parent) use ($field) {
$absolute = $field->model()->is($parent) === false;
return [
'filename' => $file->filename(),
'dragText' => $file->panel()->dragText('auto', $absolute),
];
});
return $this->field()->upload($this, $uploads, fn ($file, $parent) => [
'filename' => $file->filename(),
'dragText' => $file->panel()->dragText(
absolute: $field->model()->is($parent) === false
),
]);
}
]
];

View file

@ -1,6 +1,6 @@
<?php
use Kirby\Exception\Exception;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Toolkit\Date;
use Kirby\Toolkit\I18n;
@ -97,27 +97,27 @@ return [
$format = 'H:i:s';
if ($min && $max && $value->isBetween($min, $max) === false) {
throw new Exception([
'key' => 'validation.time.between',
'data' => [
throw new InvalidArgumentException(
key: 'validation.time.between',
data: [
'min' => $min->format($format),
'max' => $min->format($format)
]
]);
} elseif ($min && $value->isMin($min) === false) {
throw new Exception([
'key' => 'validation.time.after',
'data' => [
'time' => $min->format($format),
]
]);
} elseif ($max && $value->isMax($max) === false) {
throw new Exception([
'key' => 'validation.time.before',
'data' => [
'time' => $max->format($format),
]
]);
);
}
if ($min && $value->isMin($min) === false) {
throw new InvalidArgumentException(
key: 'validation.time.after',
data: ['time' => $min->format($format)]
);
}
if ($max && $value->isMax($max) === false) {
throw new InvalidArgumentException(
key: 'validation.time.before',
data: ['time' => $max->format($format)]
);
}
return true;

View file

@ -65,8 +65,13 @@ return [
'validations' => [
'boolean',
'required' => function ($value) {
if ($this->isRequired() && ($value === false || $this->isEmpty($value))) {
throw new InvalidArgumentException(I18n::translate('field.required'));
if (
$this->isRequired() &&
($value === false || $this->isEmptyValue($value))
) {
throw new InvalidArgumentException(
message: I18n::translate('field.required')
);
}
},
]

View file

@ -79,10 +79,10 @@ return [
$this->minlength &&
V::minLength(strip_tags($value), $this->minlength) === false
) {
throw new InvalidArgumentException([
'key' => 'validation.minlength',
'data' => ['min' => $this->minlength]
]);
throw new InvalidArgumentException(
key: 'validation.minlength',
data: ['min' => $this->minlength]
);
}
},
'maxlength' => function ($value) {
@ -90,10 +90,10 @@ return [
$this->maxlength &&
V::maxLength(strip_tags($value), $this->maxlength) === false
) {
throw new InvalidArgumentException([
'key' => 'validation.maxlength',
'data' => ['max' => $this->maxlength]
]);
throw new InvalidArgumentException(
key: 'validation.maxlength',
data: ['max' => $this->maxlength]
);
}
},
]

View file

@ -55,7 +55,7 @@ if (Helpers::hasOverride('collection') === false) { // @codeCoverageIgnore
* Returns the result of a collection by name
*
* @return \Kirby\Toolkit\Collection|null
* @todo 5.0 Add return type declaration
* @todo 6.0 Add return type declaration
*/
function collection(string $name, array $options = [])
{

View file

@ -2,6 +2,7 @@
use Kirby\Cms\App;
use Kirby\Cms\Blocks;
use Kirby\Cms\Collection;
use Kirby\Cms\File;
use Kirby\Cms\Files;
use Kirby\Cms\Html;
@ -80,7 +81,9 @@ return function (App $app) {
$message .= ' on parent "' . $parent->title() . '"';
}
throw new InvalidArgumentException($message);
throw new InvalidArgumentException(
message: $message
);
}
},
@ -130,6 +133,18 @@ return function (App $app) {
return Str::date($time, $format);
},
/**
* Parse yaml entries data and convert it to a
* collection of field objects
*/
'toEntries' => function (Field $field): Collection {
$entries = new Collection(parent: $field->parent());
foreach ($field->yaml() as $index => $entry) {
$entries->append(new Field($field->parent(), $index, $entry));
}
return $entries;
},
/**
* Returns a file object from a filename in the field
*/
@ -266,7 +281,9 @@ return function (App $app) {
$message .= ' on parent "' . $parent->id() . '"';
}
throw new InvalidArgumentException($message);
throw new InvalidArgumentException(
message: $message
);
}
},

View file

@ -40,19 +40,36 @@ return function (array $props) {
if ($drafts !== false) {
$sections['drafts'] = $section(I18n::translate('pages.status.draft'), 'drafts', $drafts);
$sections['drafts'] = $section(
I18n::translate('pages.status.draft'),
'drafts',
$drafts
);
}
if ($unlisted !== false) {
$sections['unlisted'] = $section(I18n::translate('pages.status.unlisted'), 'unlisted', $unlisted);
$sections['unlisted'] = $section(
I18n::translate('pages.status.unlisted'),
'unlisted',
$unlisted
);
}
if ($listed !== false) {
$sections['listed'] = $section(I18n::translate('pages.status.listed'), 'listed', $listed);
$sections['listed'] = $section(
I18n::translate('pages.status.listed'),
'listed',
$listed
);
}
// cleaning up
unset($props['drafts'], $props['unlisted'], $props['listed'], $props['templates']);
unset(
$props['drafts'],
$props['unlisted'],
$props['listed'],
$props['templates']
);
return array_merge($props, ['sections' => $sections]);
return [...$props, 'sections' => $sections];
};

View file

@ -3,9 +3,9 @@
use Kirby\Cms\App;
use Kirby\Cms\LanguageRoutes;
use Kirby\Cms\Media;
use Kirby\Cms\PluginAssets;
use Kirby\Panel\Panel;
use Kirby\Panel\Plugins;
use Kirby\Plugin\Assets;
use Kirby\Toolkit\Str;
use Kirby\Uuid\Uuid;
@ -71,7 +71,7 @@ return function (App $kirby) {
string $hash,
string $path
) {
return PluginAssets::resolve(
return Assets::resolve(
$provider . '/' . $pluginName,
$hash,
$path

View file

@ -1,7 +1,5 @@
<?php
use Kirby\Cms\Page;
use Kirby\Cms\Site;
use Kirby\Form\Form;
return [
@ -12,45 +10,19 @@ return [
],
'computed' => [
'form' => function () {
$fields = $this->fields;
$disabled = $this->model->permissions()->update() === false;
$lang = $this->model->kirby()->languageCode();
$content = $this->model->content($lang)->toArray();
if ($disabled === true) {
foreach ($fields as $key => $props) {
$fields[$key]['disabled'] = true;
}
}
return new Form([
'fields' => $fields,
'values' => $content,
'model' => $this->model,
'strict' => true
]);
return new Form(
fields: $this->fields,
model: $this->model,
language: 'current'
);
},
'fields' => function () {
$fields = $this->form->fields()->toArray();
if (
$this->model instanceof Page ||
$this->model instanceof Site
) {
// the title should never be updated directly via
// fields section to avoid conflicts with the rename dialog
unset($fields['title']);
}
foreach ($fields as $index => $props) {
unset($fields[$index]['value']);
}
return $fields;
return $this->form->fields()->toProps();
}
],
'methods' => [
'errors' => function () {
$this->form->fill($this->model->content('current')->toArray());
return $this->form->errors();
}
],

View file

@ -6,6 +6,7 @@ use Kirby\Toolkit\I18n;
return [
'mixins' => [
'batch',
'details',
'empty',
'headline',
@ -90,14 +91,15 @@ return [
$files = $files->flip();
}
return $files;
},
'modelsPaginated' => function () {
// apply the default pagination
$files = $files->paginate([
return $this->models()->paginate([
'page' => $this->page,
'limit' => $this->limit,
'method' => 'none' // the page is manually provided
]);
return $files;
},
'files' => function () {
return $this->models;
@ -105,15 +107,16 @@ return [
'data' => function () {
$data = [];
// the drag text needs to be absolute when the files come from
// a different parent model
$dragTextAbsolute = $this->model->is($this->parent) === false;
foreach ($this->models as $file) {
$panel = $file->panel();
foreach ($this->modelsPaginated() as $file) {
$panel = $file->panel();
$permissions = $file->permissions();
$item = [
'dragText' => $panel->dragText('auto', $dragTextAbsolute),
'dragText' => $panel->dragText(
// the drag text needs to be absolute
// when the files come from a different parent model
absolute: $this->model->is($this->parent) === false
),
'extension' => $file->extension(),
'filename' => $file->filename(),
'id' => $file->id(),
@ -125,6 +128,10 @@ return [
'link' => $panel->url(true),
'mime' => $file->mime(),
'parent' => $file->parent()->panel()->path(),
'permissions' => [
'delete' => $permissions->can('delete'),
'sort' => $permissions->can('sort'),
],
'template' => $file->template(),
'text' => $file->toSafeString($this->text),
'url' => $file->url(),
@ -140,7 +147,7 @@ return [
return $data;
},
'total' => function () {
return $this->models->pagination()->total();
return $this->models()->count();
},
'errors' => function () {
$errors = [];
@ -179,14 +186,8 @@ return [
}
// count all uploaded files
$max = $this->max ? $this->max - $this->total : null;
if ($this->max && $this->total === $this->max - 1) {
$multiple = false;
} else {
$multiple = true;
}
$max = $this->max ? $this->max - $this->total : null;
$multiple = !$max || $max > 1;
$template = $this->template === 'default' ? null : $this->template;
return [
@ -220,6 +221,15 @@ return [
return true;
}
],
[
'pattern' => 'delete',
'method' => 'DELETE',
'action' => function () {
return $this->section()->deleteSelected(
ids: $this->requestBody('ids'),
);
}
]
];
},
@ -231,6 +241,7 @@ return [
'options' => [
'accept' => $this->accept,
'apiUrl' => $this->parent->apiUrl(true) . '/sections/' . $this->name,
'batch' => $this->batch,
'columns' => $this->columnsWithTypes(),
'empty' => $this->empty,
'headline' => $this->headline,

View file

@ -0,0 +1,45 @@
<?php
use Kirby\Exception\Exception;
use Kirby\Exception\PermissionException;
use Kirby\Toolkit\I18n;
return [
'props' => [
/**
* Activates the batch delete option for the section
*/
'batch' => function (bool $batch = false) {
return $batch;
},
],
'methods' => [
'deleteSelected' => function (array $ids): bool {
if ($ids === []) {
return true;
}
// check if batch deletion is allowed
if ($this->batch() === false) {
throw new PermissionException(
message: 'The section does not support batch actions'
);
}
$min = $this->min();
// check if the section has enough items after the deletion
if ($this->total() - count($ids) < $min) {
throw new Exception(
message: I18n::template('error.section.' . $this->type() . '.min.' . I18n::form($min), [
'min' => $min,
'section' => $this->headline()
])
);
}
$this->models()->delete($ids);
return true;
}
]
];

View file

@ -19,7 +19,7 @@ return [
*/
'layout' => function (string $layout = 'list') {
$layouts = ['list', 'cardlets', 'cards', 'table'];
return in_array($layout, $layouts) ? $layout : 'list';
return in_array($layout, $layouts, true) ? $layout : 'list';
},
/**
* Whether the raw content file values should be used for the table column previews. Should not be used unless it eases performance issues in your setup introduced with Kirby 4.2

View file

@ -24,7 +24,9 @@ return [
$parent = $this->model->query($query);
if (!$parent) {
throw new Exception('The parent for the query "' . $query . '" cannot be found in the section "' . $this->name() . '"');
throw new Exception(
message: 'The parent for the query "' . $query . '" cannot be found in the section "' . $this->name() . '"'
);
}
if (
@ -33,7 +35,9 @@ return [
$parent instanceof File === false &&
$parent instanceof User === false
) {
throw new Exception('The parent for the section "' . $this->name() . '" has to be a page, site or user object');
throw new Exception(
message: 'The parent for the section "' . $this->name() . '" has to be a page, site or user object'
);
}
}

View file

@ -29,7 +29,7 @@ return [
if (
$this->type === 'pages' &&
in_array($this->status, ['listed', 'published', 'all']) === false
in_array($this->status, ['listed', 'published', 'all'], true) === false
) {
return false;
}

View file

@ -10,6 +10,7 @@ use Kirby\Toolkit\I18n;
return [
'mixins' => [
'batch',
'details',
'empty',
'headline',
@ -44,7 +45,7 @@ return [
$status = 'draft';
}
if (in_array($status, ['all', 'draft', 'published', 'listed', 'unlisted']) === false) {
if (in_array($status, ['all', 'draft', 'published', 'listed', 'unlisted'], true) === false) {
$status = 'all';
}
@ -77,7 +78,9 @@ return [
$parent instanceof Site === false &&
$parent instanceof Page === false
) {
throw new InvalidArgumentException('The parent is invalid. You must choose the site or a page as parent.');
throw new InvalidArgumentException(
message: 'The parent is invalid. You must choose the site or a page as parent.'
);
}
return $parent;
@ -111,7 +114,7 @@ return [
// filter by all set templates
if (
$this->templates &&
in_array($intendedTemplate, $this->templates) === false
in_array($intendedTemplate, $this->templates, true) === false
) {
return false;
}
@ -119,7 +122,7 @@ return [
// exclude by all ignored templates
if (
$this->templatesIgnore &&
in_array($intendedTemplate, $this->templatesIgnore) === true
in_array($intendedTemplate, $this->templatesIgnore, true) === true
) {
return false;
}
@ -147,25 +150,26 @@ return [
$pages = $pages->flip();
}
return $pages;
},
'modelsPaginated' => function () {
// pagination
$pages = $pages->paginate([
return $this->models()->paginate([
'page' => $this->page,
'limit' => $this->limit,
'method' => 'none' // the page is manually provided
]);
return $pages;
},
'pages' => function () {
return $this->models;
},
'total' => function () {
return $this->models->pagination()->total();
return $this->models()->count();
},
'data' => function () {
$data = [];
foreach ($this->models as $page) {
foreach ($this->modelsPaginated() as $page) {
$panel = $page->panel();
$permissions = $page->permissions();
@ -180,10 +184,11 @@ return [
'link' => $panel->url(true),
'parent' => $page->parentId(),
'permissions' => [
'sort' => $permissions->can('sort'),
'delete' => $permissions->can('delete'),
'changeSlug' => $permissions->can('changeSlug'),
'changeStatus' => $permissions->can('changeStatus'),
'changeTitle' => $permissions->can('changeTitle'),
'sort' => $permissions->can('sort'),
],
'status' => $page->status(),
'template' => $page->intendedTemplate()->name(),
@ -313,12 +318,28 @@ return [
return $blueprints;
},
],
// @codeCoverageIgnoreStart
'api' => function () {
return [
[
'pattern' => 'delete',
'method' => 'DELETE',
'action' => function () {
return $this->section()->deleteSelected(
ids: $this->requestBody('ids'),
);
}
]
];
},
// @codeCoverageIgnoreEnd
'toArray' => function () {
return [
'data' => $this->data,
'errors' => $this->errors,
'options' => [
'add' => $this->add,
'batch' => $this->batch,
'columns' => $this->columnsWithTypes(),
'empty' => $this->empty,
'headline' => $this->headline,

View file

@ -216,14 +216,18 @@ return [
// if url is empty, throw exception or link to the error page
if ($tag->value === null) {
if ($tag->kirby()->option('debug', false) === true) {
$error = 'The linked page cannot be found';
if (empty($tag->text) === false) {
throw new NotFoundException('The linked page cannot be found for the link text "' . $tag->text . '"');
} else {
throw new NotFoundException('The linked page cannot be found');
$error .= ' for the link text "' . $tag->text . '"';
}
} else {
$tag->value = Url::to($tag->kirby()->site()->errorPageId());
throw new NotFoundException(
message: $error
);
}
$tag->value = Url::to($tag->kirby()->site()->errorPageId());
}
return Html::a($tag->value, $tag->text, [