julienmonnerie/kirby/src/Cms/App.php

1773 lines
41 KiB
PHP
Raw Normal View History

2022-06-17 17:51:59 +02:00
<?php
namespace Kirby\Cms;
2022-12-19 14:56:05 +01:00
use Closure;
use Exception as GlobalException;
2025-04-21 18:57:21 +02:00
use Generator;
2022-06-17 17:51:59 +02:00
use Kirby\Data\Data;
2025-04-21 18:57:21 +02:00
use Kirby\Email\Email as BaseEmail;
2022-06-17 17:51:59 +02:00
use Kirby\Exception\ErrorPageException;
2022-12-19 14:56:05 +01:00
use Kirby\Exception\Exception;
2022-06-17 17:51:59 +02:00
use Kirby\Exception\InvalidArgumentException;
use Kirby\Exception\LogicException;
use Kirby\Exception\NotFoundException;
use Kirby\Filesystem\Dir;
use Kirby\Filesystem\F;
2022-08-31 15:02:43 +02:00
use Kirby\Http\Environment;
2022-06-17 17:51:59 +02:00
use Kirby\Http\Request;
2022-06-17 18:02:55 +02:00
use Kirby\Http\Response;
2025-04-21 18:57:21 +02:00
use Kirby\Http\Route;
2022-06-17 17:51:59 +02:00
use Kirby\Http\Router;
use Kirby\Http\Uri;
use Kirby\Http\Visitor;
use Kirby\Session\AutoSession;
2025-04-21 18:57:21 +02:00
use Kirby\Session\Session;
2023-04-14 16:34:06 +02:00
use Kirby\Template\Snippet;
2025-04-21 18:57:21 +02:00
use Kirby\Template\Template;
2022-06-17 17:51:59 +02:00
use Kirby\Text\KirbyTag;
use Kirby\Text\KirbyTags;
use Kirby\Toolkit\A;
use Kirby\Toolkit\Config;
use Kirby\Toolkit\Controller;
2025-04-21 18:57:21 +02:00
use Kirby\Toolkit\LazyValue;
use Kirby\Toolkit\Locale;
2022-08-31 15:02:43 +02:00
use Kirby\Toolkit\Str;
2022-12-19 14:56:05 +01:00
use Kirby\Uuid\Uuid;
2022-06-17 17:51:59 +02:00
use Throwable;
/**
* The `$kirby` object is the app instance of
* your Kirby installation. It's the central
* starting point to get all the different
* aspects of your site, like the options, urls,
* roots, languages, roles, etc.
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class App
{
2022-08-31 15:02:43 +02:00
use AppCaches;
use AppErrors;
use AppPlugins;
use AppTranslations;
use AppUsers;
public const CLASS_ALIAS = 'kirby';
2025-04-21 18:57:21 +02:00
protected static App|null $instance = null;
protected static string|null $version = null;
public array $data = [];
protected Api|null $api = null;
protected Collections|null $collections = null;
protected Core $core;
protected Language|null $defaultLanguage = null;
protected Environment|null $environment = null;
protected Language|null $language = null;
protected Languages|null $languages = null;
protected ContentLocks|null $locks = null;
protected bool|null $multilang = null;
protected string|null $nonce = null;
protected array $options;
protected string|null $path = null;
protected Request|null $request = null;
protected Responder|null $response = null;
protected Roles|null $roles = null;
protected Ingredients $roots;
protected array|null $routes = null;
protected Router|null $router = null;
protected AutoSession|null $sessionHandler = null;
protected Site|null $site = null;
protected System|null $system = null;
protected Ingredients $urls;
protected Visitor|null $visitor = null;
protected array $propertyData;
2022-08-31 15:02:43 +02:00
/**
* Creates a new App instance
*
* @param bool $setInstance If false, the instance won't be set globally
*/
public function __construct(array $props = [], bool $setInstance = true)
{
$this->core = new Core($this);
// register all roots to be able to load stuff afterwards
$this->bakeRoots($props['roots'] ?? []);
try {
// stuff from config and additional options
$this->optionsFromConfig();
$this->optionsFromProps($props['options'] ?? []);
$this->optionsFromEnvironment($props);
} finally {
// register the Whoops error handler inside of a
// try-finally block to ensure it's still registered
// even if there is a problem loading the configurations
$this->handleErrors();
}
2025-04-21 18:57:21 +02:00
$this->propertyData = $props;
2022-08-31 15:02:43 +02:00
// a custom request setup must come before defining the path
$this->setRequest($props['request'] ?? null);
// set the path to make it available for the url bakery
$this->setPath($props['path'] ?? null);
// create all urls after the config, so possible
// options can be taken into account
$this->bakeUrls($props['urls'] ?? []);
// configurable properties
2025-04-21 18:57:21 +02:00
$this->setLanguages($props['languages'] ?? null);
$this->setRoles($props['roles'] ?? null);
$this->setSite($props['site'] ?? null);
$this->setUser($props['user'] ?? null);
$this->setUsers($props['users'] ?? null);
2022-08-31 15:02:43 +02:00
// set the singleton
if (static::$instance === null || $setInstance === true) {
2025-04-21 18:57:21 +02:00
static::$instance = ModelWithContent::$kirby = Model::$kirby = $this;
2022-08-31 15:02:43 +02:00
}
// setup the I18n class with the translation loader
$this->i18n();
// load all extensions
$this->extensionsFromSystem();
$this->extensionsFromProps($props);
$this->extensionsFromPlugins();
$this->extensionsFromOptions();
$this->extensionsFromFolders();
// trigger hook for use in plugins
$this->trigger('system.loadPlugins:after');
// execute a ready callback from the config
$this->optionsFromReadyCallback();
// bake config
$this->bakeOptions();
}
/**
* Improved `var_dump` output
*
2025-04-21 18:57:21 +02:00
* @codeCoverageIgnore
2022-08-31 15:02:43 +02:00
*/
public function __debugInfo(): array
{
return [
'languages' => $this->languages(),
'options' => $this->options(),
'request' => $this->request(),
'roots' => $this->roots(),
'site' => $this->site(),
'urls' => $this->urls(),
2025-04-21 18:57:21 +02:00
'version' => static::version(),
2022-08-31 15:02:43 +02:00
];
}
/**
* Returns the Api instance
*
* @internal
*/
2025-04-21 18:57:21 +02:00
public function api(): Api
2022-08-31 15:02:43 +02:00
{
if ($this->api !== null) {
return $this->api;
}
$root = $this->root('kirby') . '/config/api';
$extensions = $this->extensions['api'] ?? [];
$routes = (include $root . '/routes.php')($this);
$api = [
'debug' => $this->option('debug', false),
'authentication' => $extensions['authentication'] ?? include $root . '/authentication.php',
'data' => $extensions['data'] ?? [],
'collections' => array_merge($extensions['collections'] ?? [], include $root . '/collections.php'),
'models' => array_merge($extensions['models'] ?? [], include $root . '/models.php'),
'routes' => array_merge($routes, $extensions['routes'] ?? []),
'kirby' => $this,
];
return $this->api = new Api($api);
}
/**
* Applies a hook to the given value
*
* @param string $name Full event name
* @param array $args Associative array of named event arguments
* @param string $modify Key in $args that is modified by the hooks
* @param \Kirby\Cms\Event|null $originalEvent Event object (internal use)
* @return mixed Resulting value as modified by the hooks
*/
2025-04-21 18:57:21 +02:00
public function apply(
string $name,
array $args,
string $modify,
Event|null $originalEvent = null
): mixed {
2022-08-31 15:02:43 +02:00
$event = $originalEvent ?? new Event($name, $args);
if ($functions = $this->extension('hooks', $name)) {
foreach ($functions as $function) {
// bind the App object to the hook
$newValue = $event->call($this, $function);
// update value if one was returned
if ($newValue !== null) {
$event->updateArgument($modify, $newValue);
}
}
}
// apply wildcard hooks if available
$nameWildcards = $event->nameWildcards();
if ($originalEvent === null && count($nameWildcards) > 0) {
foreach ($nameWildcards as $nameWildcard) {
// the $event object is passed by reference
// and will be modified down the chain
$this->apply($nameWildcard, $event->arguments(), $modify, $event);
}
}
return $event->argument($modify);
}
/**
* Normalizes and globally sets the configured options
*
* @return $this
*/
2025-04-21 18:57:21 +02:00
protected function bakeOptions(): static
2022-08-31 15:02:43 +02:00
{
// convert the old plugin option syntax to the new one
foreach ($this->options as $key => $value) {
// detect option keys with the `vendor.plugin.option` format
if (preg_match('/^([a-z0-9-]+\.[a-z0-9-]+)\.(.*)$/i', $key, $matches) === 1) {
2025-04-21 18:57:21 +02:00
[, $plugin, $option] = $matches;
2022-08-31 15:02:43 +02:00
// verify that it's really a plugin option
if (isset(static::$plugins[str_replace('.', '/', $plugin)]) !== true) {
continue;
}
// ensure that the target option array exists
// (which it will if the plugin has any options)
if (isset($this->options[$plugin]) !== true) {
$this->options[$plugin] = []; // @codeCoverageIgnore
}
// move the option to the plugin option array
// don't overwrite nested arrays completely but merge them
$this->options[$plugin] = array_replace_recursive($this->options[$plugin], [$option => $value]);
unset($this->options[$key]);
}
}
Config::$data = $this->options;
return $this;
}
/**
* Sets the directory structure
*
* @return $this
*/
2025-04-21 18:57:21 +02:00
protected function bakeRoots(array|null $roots = null): static
2022-08-31 15:02:43 +02:00
{
$roots = array_merge($this->core->roots(), (array)$roots);
$this->roots = Ingredients::bake($roots);
return $this;
}
/**
* Sets the Url structure
*
* @return $this
*/
2025-04-21 18:57:21 +02:00
protected function bakeUrls(array|null $urls = null): static
2022-08-31 15:02:43 +02:00
{
$urls = array_merge($this->core->urls(), (array)$urls);
$this->urls = Ingredients::bake($urls);
return $this;
}
/**
* Returns all available blueprints for this installation
*/
public function blueprints(string $type = 'pages'): array
{
$blueprints = [];
foreach ($this->extensions('blueprints') as $name => $blueprint) {
if (dirname($name) === $type) {
$name = basename($name);
$blueprints[$name] = $name;
}
}
try {
// protect against path traversal attacks
$root = $this->root('blueprints') . '/' . $type;
$realpath = Dir::realpath($root, $this->root('blueprints'));
foreach (glob($realpath . '/*.yml') as $blueprint) {
$name = F::name($blueprint);
$blueprints[$name] = $name;
}
} catch (GlobalException) {
// if the realpath operation failed, the following glob was skipped,
// keeping just the blueprints from extensions
2022-08-31 15:02:43 +02:00
}
ksort($blueprints);
return array_values($blueprints);
}
/**
* Calls any Kirby route
*/
2025-04-21 18:57:21 +02:00
public function call(string|null $path = null, string|null $method = null): mixed
2022-08-31 15:02:43 +02:00
{
$path ??= $this->path();
$method ??= $this->request()->method();
return $this->router()->call($path, $method);
}
/**
* Creates an instance with the same
* initial properties
*
* @param bool $setInstance If false, the instance won't be set globally
*/
2025-04-21 18:57:21 +02:00
public function clone(array $props = [], bool $setInstance = true): static
2022-08-31 15:02:43 +02:00
{
$props = array_replace_recursive($this->propertyData, $props);
$clone = new static($props, $setInstance);
$clone->data = $this->data;
return $clone;
}
/**
* Returns a specific user-defined collection
* by name. All relevant dependencies are
* automatically injected
*
2024-12-20 12:37:52 +01:00
* @return \Kirby\Toolkit\Collection|null
* @todo 5.0 Add return type declaration
2022-08-31 15:02:43 +02:00
*/
2024-12-20 12:37:52 +01:00
public function collection(string $name, array $options = [])
2022-08-31 15:02:43 +02:00
{
2024-12-20 12:37:52 +01:00
return $this->collections()->get($name, array_merge($options, [
2022-08-31 15:02:43 +02:00
'kirby' => $this,
2024-12-20 12:37:52 +01:00
'site' => $site = $this->site(),
2025-04-21 18:57:21 +02:00
'pages' => new LazyValue(fn () => $site->children()),
'users' => new LazyValue(fn () => $this->users())
2024-12-20 12:37:52 +01:00
]));
2022-08-31 15:02:43 +02:00
}
/**
* Returns all user-defined collections
*/
2024-12-20 12:37:52 +01:00
public function collections(): Collections
2022-08-31 15:02:43 +02:00
{
2022-12-19 14:56:05 +01:00
return $this->collections ??= new Collections();
2022-08-31 15:02:43 +02:00
}
/**
* Returns a core component
*
* @internal
*/
2025-04-21 18:57:21 +02:00
public function component(string $name): mixed
2022-08-31 15:02:43 +02:00
{
return $this->extensions['components'][$name] ?? null;
}
/**
* Returns the content extension
*
* @internal
*/
public function contentExtension(): string
{
return $this->options['content']['extension'] ?? 'txt';
}
/**
* Returns files that should be ignored when scanning folders
*
* @internal
*/
public function contentIgnore(): array
{
return $this->options['content']['ignore'] ?? Dir::$ignore;
}
/**
* Generates a non-guessable token based on model
* data and a configured salt
*
* @param mixed $model Object to pass to the salt callback if configured
* @param string $value Model data to include in the generated token
*/
2025-04-21 18:57:21 +02:00
public function contentToken(mixed $model, string $value): string
2022-08-31 15:02:43 +02:00
{
if (method_exists($model, 'root') === true) {
$default = $model->root();
} else {
$default = $this->root('content');
}
$salt = $this->option('content.salt', $default);
2022-12-19 14:56:05 +01:00
if ($salt instanceof Closure) {
2022-08-31 15:02:43 +02:00
$salt = $salt($model);
}
return hash_hmac('sha1', $value, $salt);
}
/**
* Calls a page controller by name
* and with the given arguments
*/
2025-04-21 18:57:21 +02:00
public function controller(
string $name,
array $arguments = [],
string $contentType = 'html'
): array {
2022-08-31 15:02:43 +02:00
$name = basename(strtolower($name));
if ($controller = $this->controllerLookup($name, $contentType)) {
return (array)$controller->call($this, $arguments);
}
if ($contentType !== 'html') {
// no luck for a specific representation controller?
// let's try the html controller instead
if ($controller = $this->controllerLookup($name)) {
return (array)$controller->call($this, $arguments);
}
}
// still no luck? Let's take the site controller
if ($controller = $this->controllerLookup('site')) {
return (array)$controller->call($this, $arguments);
}
return [];
}
/**
* Try to find a controller by name
*/
2025-04-21 18:57:21 +02:00
protected function controllerLookup(
string $name,
string $contentType = 'html'
): Controller|null {
2022-08-31 15:02:43 +02:00
if ($contentType !== null && $contentType !== 'html') {
$name .= '.' . $contentType;
}
2023-04-14 16:34:06 +02:00
// controller from site root
$controller = Controller::load($this->root('controllers') . '/' . $name . '.php', $this->root('controllers'));
2023-04-14 16:34:06 +02:00
// controller from extension
$controller ??= $this->extension('controllers', $name);
if ($controller instanceof Controller) {
2022-08-31 15:02:43 +02:00
return $controller;
}
2023-04-14 16:34:06 +02:00
if ($controller !== null) {
2022-12-19 14:56:05 +01:00
return new Controller($controller);
2022-08-31 15:02:43 +02:00
}
return null;
}
/**
* Get access to object that lists
* all parts of Kirby core
*/
2025-04-21 18:57:21 +02:00
public function core(): Core
2022-08-31 15:02:43 +02:00
{
return $this->core;
}
/**
* Checks/returns a CSRF token
* @since 3.7.0
*
* @param string|null $check Pass a token here to compare it to the one in the session
* @return string|bool Either the token or a boolean check result
*/
2025-04-21 18:57:21 +02:00
public function csrf(string|null $check = null): string|bool
2022-08-31 15:02:43 +02:00
{
$session = $this->session();
// no arguments, generate/return a token
// (check explicitly if there have been no arguments at all;
// checking for null introduces a security issue because null could come
// from user input or bugs in the calling code!)
if (func_num_args() === 0) {
$token = $session->get('kirby.csrf');
if (is_string($token) !== true) {
$token = bin2hex(random_bytes(32));
$session->set('kirby.csrf', $token);
}
return $token;
}
// argument has been passed, check the token
if (
is_string($check) === true &&
is_string($session->get('kirby.csrf')) === true
) {
return hash_equals($session->get('kirby.csrf'), $check) === true;
}
return false;
}
2025-04-21 18:57:21 +02:00
/**
* Returns the current language, if set by `static::setCurrentLanguage`
*/
public function currentLanguage(): Language|null
{
return $this->language ??= $this->defaultLanguage();
}
2022-08-31 15:02:43 +02:00
/**
* Returns the default language object
*/
2023-06-01 16:54:20 +02:00
public function defaultLanguage(): Language|null
2022-08-31 15:02:43 +02:00
{
2022-12-19 14:56:05 +01:00
return $this->defaultLanguage ??= $this->languages()->default();
2022-08-31 15:02:43 +02:00
}
/**
* Destroy the instance singleton and
* purge other static props
*
* @internal
*/
public static function destroy(): void
{
static::$plugins = [];
static::$instance = null;
}
/**
* Detect the preferred language from the visitor object
*/
2023-06-01 16:54:20 +02:00
public function detectedLanguage(): Language|null
2022-08-31 15:02:43 +02:00
{
$languages = $this->languages();
$visitor = $this->visitor();
2023-06-01 16:54:20 +02:00
foreach ($visitor->acceptedLanguages() as $acceptedLang) {
2024-12-20 12:37:52 +01:00
$closure = function ($language) use ($acceptedLang) {
$languageLocale = $language->locale(LC_ALL);
$acceptedLocale = $acceptedLang->locale();
return $languageLocale === $acceptedLocale ||
$acceptedLocale === Str::substr($languageLocale, 0, 2);
};
2023-06-01 16:54:20 +02:00
if ($language = $languages->filter($closure)?->first()) {
2022-08-31 15:02:43 +02:00
return $language;
}
}
2023-06-01 16:54:20 +02:00
foreach ($visitor->acceptedLanguages() as $acceptedLang) {
if ($language = $languages->findBy('code', $acceptedLang->code())) {
2022-08-31 15:02:43 +02:00
return $language;
}
}
return $this->defaultLanguage();
}
/**
* Returns the Email singleton
*/
2025-04-21 18:57:21 +02:00
public function email(mixed $preset = [], array $props = []): BaseEmail
2022-08-31 15:02:43 +02:00
{
$debug = $props['debug'] ?? false;
$props = (new Email($preset, $props))->toArray();
return ($this->component('email'))($this, $props, $debug);
}
/**
* Returns the environment object with access
* to the detected host, base url and dedicated options
*/
2025-04-21 18:57:21 +02:00
public function environment(): Environment
2022-08-31 15:02:43 +02:00
{
2025-04-21 18:57:21 +02:00
return $this->environment ??= new Environment();
2022-08-31 15:02:43 +02:00
}
/**
* Finds any file in the content directory
*/
2025-04-21 18:57:21 +02:00
public function file(
string $path,
mixed $parent = null,
bool $drafts = true
): File|null {
2022-12-19 14:56:05 +01:00
// find by global UUID
if (Uuid::is($path, 'file') === true) {
// prefer files of parent, when parent given
return Uuid::for($path, $parent?->files())->model();
}
2025-04-21 18:57:21 +02:00
$parent ??= $this->site();
2022-08-31 15:02:43 +02:00
$id = dirname($path);
$filename = basename($path);
2022-12-19 14:56:05 +01:00
if ($parent instanceof User) {
2022-08-31 15:02:43 +02:00
return $parent->file($filename);
}
2022-12-19 14:56:05 +01:00
if ($parent instanceof File) {
2022-08-31 15:02:43 +02:00
$parent = $parent->parent();
}
if ($id === '.') {
2022-12-19 14:56:05 +01:00
return $parent->file($filename) ?? $this->site()->file($filename);
2022-08-31 15:02:43 +02:00
}
if ($page = $this->page($id, $parent, $drafts)) {
return $page->file($filename);
}
if ($page = $this->page($id, null, $drafts)) {
return $page->file($filename);
}
return null;
}
/**
* Return an image from any page
* specified by the path
*
* Example:
2025-04-21 18:57:21 +02:00
* <?= $kirby->image('some/page/myimage.jpg') ?>
2022-08-31 15:02:43 +02:00
*
* @todo merge with App::file()
*/
2025-04-21 18:57:21 +02:00
public function image(string|null $path = null): File|null
2022-08-31 15:02:43 +02:00
{
if ($path === null) {
return $this->site()->page()->image();
}
$uri = dirname($path);
$filename = basename($path);
if ($uri === '.') {
$uri = null;
}
2022-12-19 14:56:05 +01:00
$parent = match ($uri) {
'/' => $this->site(),
null => $this->site()->page(),
default => $this->site()->page($uri)
};
2022-08-31 15:02:43 +02:00
2022-12-19 14:56:05 +01:00
return $parent?->image($filename);
2022-08-31 15:02:43 +02:00
}
/**
* Returns the current App instance
*
* @param bool $lazy If `true`, the instance is only returned if already existing
2022-12-19 14:56:05 +01:00
* @psalm-return ($lazy is false ? static : static|null)
2022-08-31 15:02:43 +02:00
*/
2025-04-21 18:57:21 +02:00
public static function instance(
self|null $instance = null,
bool $lazy = false
): static|null {
2022-12-19 14:56:05 +01:00
if ($instance !== null) {
return static::$instance = $instance;
}
if ($lazy === true) {
return static::$instance;
2022-08-31 15:02:43 +02:00
}
2022-12-19 14:56:05 +01:00
return static::$instance ?? new static();
2022-08-31 15:02:43 +02:00
}
/**
* Takes almost any kind of input and
* tries to convert it into a valid response
*
* @internal
*/
2025-04-21 18:57:21 +02:00
public function io(mixed $input): Response
2022-08-31 15:02:43 +02:00
{
// use the current response configuration
$response = $this->response();
// any direct exception will be turned into an error page
2022-12-19 14:56:05 +01:00
if ($input instanceof Throwable) {
if ($input instanceof Exception) {
2022-08-31 15:02:43 +02:00
$code = $input->getHttpCode();
} else {
$code = $input->getCode();
}
$message = $input->getMessage();
if ($code < 400 || $code > 599) {
$code = 500;
}
if ($errorPage = $this->site()->errorPage()) {
return $response->code($code)->send($errorPage->render([
'errorCode' => $code,
'errorMessage' => $message,
'errorType' => get_class($input)
]));
}
return $response
->code($code)
->type('text/html')
->send($message);
}
// Empty input
if (empty($input) === true) {
return $this->io(new NotFoundException());
}
// (Modified) global response configuration, e.g. in routes
2022-12-19 14:56:05 +01:00
if ($input instanceof Responder) {
2022-08-31 15:02:43 +02:00
// return the passed object unmodified (without injecting headers
// from the global object) to allow a complete response override
// https://github.com/getkirby/kirby/pull/4144#issuecomment-1034766726
return $input->send();
}
// Responses
2022-12-19 14:56:05 +01:00
if ($input instanceof Response) {
2022-08-31 15:02:43 +02:00
$data = $input->toArray();
// inject headers from the global response configuration
// lazily (only if they are not already set);
// the case-insensitive nature of headers will be
// handled by PHP's `header()` function
2022-12-19 14:56:05 +01:00
$data['headers'] = array_merge(
$response->headers(),
$data['headers']
);
2022-08-31 15:02:43 +02:00
return new Response($data);
}
// Pages
2022-12-19 14:56:05 +01:00
if ($input instanceof Page) {
2022-08-31 15:02:43 +02:00
try {
$html = $input->render();
2025-04-21 18:57:21 +02:00
} catch (ErrorPageException|NotFoundException $e) {
2022-08-31 15:02:43 +02:00
return $this->io($e);
}
2022-12-19 14:56:05 +01:00
if (
$input->isErrorPage() === true &&
$response->code() === null
) {
$response->code(404);
2022-08-31 15:02:43 +02:00
}
return $response->send($html);
}
// Files
2022-12-19 14:56:05 +01:00
if ($input instanceof File) {
2022-08-31 15:02:43 +02:00
return $response->redirect($input->mediaUrl(), 307)->send();
}
// Simple HTML response
if (is_string($input) === true) {
return $response->send($input);
}
// array to json conversion
if (is_array($input) === true) {
return $response->json($input)->send();
}
throw new InvalidArgumentException('Unexpected input');
}
/**
* Renders a single KirbyTag with the given attributes
*
* @internal
* @param string|array $type Tag type or array with all tag arguments
* (the key of the first element becomes the type)
*/
2025-04-21 18:57:21 +02:00
public function kirbytag(
string|array $type,
string|null $value = null,
array $attr = [],
array $data = []
): string {
2022-08-31 15:02:43 +02:00
if (is_array($type) === true) {
$kirbytag = $type;
$type = key($kirbytag);
$value = current($kirbytag);
$attr = $kirbytag;
// check data attribute and separate from attr data if exists
if (isset($attr['data']) === true) {
$data = $attr['data'];
unset($attr['data']);
}
}
2025-04-21 18:57:21 +02:00
$data['kirby'] ??= $this;
$data['site'] ??= $data['kirby']->site();
$data['parent'] ??= $data['site']->page();
2022-08-31 15:02:43 +02:00
return (new KirbyTag($type, $value, $attr, $data, $this->options))->render();
}
/**
* KirbyTags Parser
*
* @internal
*/
2025-04-21 18:57:21 +02:00
public function kirbytags(string|null $text = null, array $data = []): string
2022-08-31 15:02:43 +02:00
{
$data['kirby'] ??= $this;
$data['site'] ??= $data['kirby']->site();
$data['parent'] ??= $data['site']->page();
$options = $this->options;
$text = $this->apply('kirbytags:before', compact('text', 'data', 'options'), 'text');
$text = KirbyTags::parse($text, $data, $options);
$text = $this->apply('kirbytags:after', compact('text', 'data', 'options'), 'text');
return $text;
}
/**
* Parses KirbyTags first and Markdown afterwards
*
* @internal
*/
2025-04-21 18:57:21 +02:00
public function kirbytext(string|null $text = null, array $options = []): string
2022-08-31 15:02:43 +02:00
{
$text = $this->apply('kirbytext:before', compact('text'), 'text');
$text = $this->kirbytags($text, $options);
2022-12-19 14:56:05 +01:00
$text = $this->markdown($text, $options['markdown'] ?? []);
2022-08-31 15:02:43 +02:00
if ($this->option('smartypants', false) !== false) {
$text = $this->smartypants($text);
}
$text = $this->apply('kirbytext:after', compact('text'), 'text');
return $text;
}
/**
2025-04-21 18:57:21 +02:00
* Returns the language by code or shortcut (`default`, `current`).
* Passing `null` is an alias for passing `current`
2022-08-31 15:02:43 +02:00
*/
2025-04-21 18:57:21 +02:00
public function language(string|null $code = null): Language|null
2022-08-31 15:02:43 +02:00
{
if ($this->multilang() === false) {
return null;
}
2025-04-21 18:57:21 +02:00
return match ($code ?? 'current') {
'default' => $this->defaultLanguage(),
'current' => $this->currentLanguage(),
default => $this->languages()->find($code)
};
2022-08-31 15:02:43 +02:00
}
/**
* Returns the current language code
*
* @internal
*/
2025-04-21 18:57:21 +02:00
public function languageCode(string|null $languageCode = null): string|null
2022-08-31 15:02:43 +02:00
{
2022-12-19 14:56:05 +01:00
return $this->language($languageCode)?->code();
2022-08-31 15:02:43 +02:00
}
/**
* Returns all available site languages
*/
2022-12-19 14:56:05 +01:00
public function languages(bool $clone = true): Languages
2022-08-31 15:02:43 +02:00
{
2025-04-21 18:57:21 +02:00
if ($clone === false) {
$this->multilang = null;
$this->defaultLanguage = null;
}
2022-08-31 15:02:43 +02:00
if ($this->languages !== null) {
return $clone === true ? clone $this->languages : $this->languages;
}
return $this->languages = Languages::load();
}
/**
* Access Kirby's part loader
*/
2025-04-21 18:57:21 +02:00
public function load(): Loader
2022-08-31 15:02:43 +02:00
{
return new Loader($this);
}
/**
* Returns the app's locks object
*/
public function locks(): ContentLocks
{
2025-04-21 18:57:21 +02:00
return $this->locks ??= new ContentLocks();
2022-08-31 15:02:43 +02:00
}
/**
* Parses Markdown
*
* @internal
*/
2025-04-21 18:57:21 +02:00
public function markdown(string|null $text = null, array|null $options = null): string
2022-08-31 15:02:43 +02:00
{
// merge global options with local options
$options = array_merge(
$this->options['markdown'] ?? [],
(array)$options
);
2022-12-19 14:56:05 +01:00
return ($this->component('markdown'))($this, $text, $options);
2022-08-31 15:02:43 +02:00
}
/**
2025-04-21 18:57:21 +02:00
* Yields all models (site, pages, files and users) of this site
* @since 4.0.0
2022-08-31 15:02:43 +02:00
*
2025-04-21 18:57:21 +02:00
* @return \Generator|\Kirby\Cms\ModelWithContent[]
2022-08-31 15:02:43 +02:00
*/
2025-04-21 18:57:21 +02:00
public function models(): Generator
2022-08-31 15:02:43 +02:00
{
2025-04-21 18:57:21 +02:00
$site = $this->site();
yield from $site->files();
yield $site;
foreach ($site->index(true) as $page) {
yield from $page->files();
yield $page;
2022-08-31 15:02:43 +02:00
}
2025-04-21 18:57:21 +02:00
foreach ($this->users() as $user) {
yield from $user->files();
yield $user;
}
}
/**
* Check for a multilang setup
*/
public function multilang(): bool
{
return $this->multilang ??= $this->languages()->count() !== 0;
2022-08-31 15:02:43 +02:00
}
/**
* Returns the nonce, which is used
* in the panel for inline scripts
* @since 3.3.0
*/
public function nonce(): string
{
2022-12-19 14:56:05 +01:00
return $this->nonce ??= base64_encode(random_bytes(20));
2022-08-31 15:02:43 +02:00
}
/**
* Load a specific configuration option
*/
2025-04-21 18:57:21 +02:00
public function option(string $key, mixed $default = null): mixed
2022-08-31 15:02:43 +02:00
{
return A::get($this->options, $key, $default);
}
/**
* Returns all configuration options
*/
public function options(): array
{
return $this->options;
}
/**
* Load all options from files in site/config
*/
protected function optionsFromConfig(): array
{
// create an empty config container
Config::$data = [];
// load the main config options
$root = $this->root('config');
2022-12-19 14:56:05 +01:00
$options = F::load($root . '/config.php', [], allowOutput: false);
2022-08-31 15:02:43 +02:00
// merge into one clean options array
return $this->options = array_replace_recursive(Config::$data, $options);
}
/**
* Load all options for the current
* server environment
*/
protected function optionsFromEnvironment(array $props = []): array
{
2022-12-19 14:56:05 +01:00
$root = $this->root('config');
// first load `config/env.php` to access its `url` option
$envOptions = F::load($root . '/env.php', [], allowOutput: false);
2022-08-31 15:02:43 +02:00
2022-12-19 14:56:05 +01:00
// use the option from the main `config.php`,
// but allow the `env.php` to override it
$globalUrl = $envOptions['url'] ?? $this->options['url'] ?? null;
// create the URL setup based on hostname and server IP address
2022-08-31 15:02:43 +02:00
$this->environment = new Environment([
'allowed' => $globalUrl,
'cli' => $props['cli'] ?? null,
], $props['server'] ?? null);
2022-12-19 14:56:05 +01:00
// merge into one clean options array;
// the `env.php` options always override everything else
$hostAddrOptions = $this->environment()->options($root);
$this->options = array_replace_recursive($this->options, $hostAddrOptions, $envOptions);
2022-08-31 15:02:43 +02:00
2022-12-19 14:56:05 +01:00
// reload the environment if the host/address config has overridden
2022-08-31 15:02:43 +02:00
// the `url` option; this ensures that the base URL is correct
$envUrl = $this->options['url'] ?? null;
if ($envUrl !== $globalUrl) {
$this->environment->detect([
'allowed' => $envUrl,
'cli' => $props['cli'] ?? null
], $props['server'] ?? null);
}
return $this->options;
}
/**
* Inject options from Kirby instance props
*/
protected function optionsFromProps(array $options = []): array
{
return $this->options = array_replace_recursive(
$this->options,
$options
);
}
/**
* Merge last-minute options from ready callback
*/
protected function optionsFromReadyCallback(): array
{
2025-04-21 18:57:21 +02:00
if (
isset($this->options['ready']) === true &&
is_callable($this->options['ready']) === true
) {
2022-08-31 15:02:43 +02:00
// fetch last-minute options from the callback
$options = (array)$this->options['ready']($this);
// inject all last-minute options recursively
$this->options = array_replace_recursive($this->options, $options);
// update the system with changed options
if (
isset($options['debug']) === true ||
isset($options['whoops']) === true ||
isset($options['editor']) === true
) {
$this->handleErrors();
}
if (isset($options['debug']) === true) {
$this->api = null;
}
if (isset($options['home']) === true || isset($options['error']) === true) {
$this->site = null;
}
// checks custom language definition for slugs
if ($slugsOption = $this->option('slugs')) {
// slugs option must be set to string or "slugs" => ["language" => "de"] as array
if (is_string($slugsOption) === true || isset($slugsOption['language']) === true) {
$this->i18n();
}
}
}
return $this->options;
}
/**
* Returns any page from the content folder
*/
2025-04-21 18:57:21 +02:00
public function page(
string|null $id = null,
Page|Site|null $parent = null,
bool $drafts = true
): Page|null {
2022-08-31 15:02:43 +02:00
if ($id === null) {
return null;
}
2025-04-21 18:57:21 +02:00
$parent ??= $this->site();
2022-08-31 15:02:43 +02:00
if ($page = $parent->find($id)) {
/**
* We passed a single $id, we can be sure that the result is
* @var \Kirby\Cms\Page $page
*/
return $page;
}
if ($drafts === true && $draft = $parent->draft($id)) {
return $draft;
}
return null;
}
/**
* Returns the request path
*/
public function path(): string
{
if (is_string($this->path) === true) {
return $this->path;
}
$current = $this->request()->path()->toString();
$index = $this->environment()->baseUri()->path()->toString();
$path = Str::afterStart($current, $index);
return $this->setPath($path)->path;
}
/**
* Returns the Response object for the
* current request
*/
2025-04-21 18:57:21 +02:00
public function render(
string|null $path = null,
string|null $method = null
): Response|null {
if ((filter_var($_ENV['KIRBY_RENDER'] ?? true, FILTER_VALIDATE_BOOLEAN)) === false) {
2022-12-19 14:56:05 +01:00
return null;
}
2022-08-31 15:02:43 +02:00
return $this->io($this->call($path, $method));
}
/**
* Returns the Request singleton
*/
2025-04-21 18:57:21 +02:00
public function request(): Request
2022-08-31 15:02:43 +02:00
{
if ($this->request !== null) {
return $this->request;
}
$env = $this->environment();
return $this->request = new Request([
'cli' => $env->cli(),
'url' => $env->requestUri()
]);
}
/**
* Path resolver for the router
*
* @internal
* @throws \Kirby\Exception\NotFoundException if the home page cannot be found
*/
2025-04-21 18:57:21 +02:00
public function resolve(
string|null $path = null,
string|null $language = null
): mixed {
2022-08-31 15:02:43 +02:00
// set the current translation
$this->setCurrentTranslation($language);
// set the current locale
$this->setCurrentLanguage($language);
2023-04-14 16:34:06 +02:00
// directly prevent path with incomplete content representation
if (Str::endsWith($path, '.') === true) {
return null;
}
2022-08-31 15:02:43 +02:00
// the site is needed a couple times here
$site = $this->site();
// use the home page
if ($path === null) {
if ($homePage = $site->homePage()) {
return $homePage;
}
throw new NotFoundException('The home page does not exist');
}
// search for the page by path
$page = $site->find($path);
// search for a draft if the page cannot be found
if (!$page && $draft = $site->draft($path)) {
2022-12-19 14:56:05 +01:00
if (
$this->user() ||
$draft->isVerified($this->request()->get('token'))
) {
2022-08-31 15:02:43 +02:00
$page = $draft;
}
}
// try to resolve content representations if the path has an extension
$extension = F::extension($path);
// no content representation? then return the page
if (empty($extension) === true) {
return $page;
}
// only try to return a representation
// when the page has been found
if ($page) {
2025-04-21 18:57:21 +02:00
// if extension is the default content type,
// redirect to page URL without extension
if ($extension === 'html') {
return Response::redirect($page->url(), 301);
}
2022-08-31 15:02:43 +02:00
try {
$response = $this->response();
$output = $page->render([], $extension);
// attach a MIME type based on the representation
// only if no custom MIME type was set
if ($response->type() === null) {
$response->type($extension);
}
return $response->body($output);
2022-12-19 14:56:05 +01:00
} catch (NotFoundException) {
2022-08-31 15:02:43 +02:00
return null;
}
}
$id = dirname($path);
$filename = basename($path);
// try to resolve image urls for pages and drafts
if ($page = $site->findPageOrDraft($id)) {
return $this->resolveFile($page->file($filename));
2022-08-31 15:02:43 +02:00
}
// try to resolve site files at least
return $this->resolveFile($site->file($filename));
}
/**
* Filters a resolved file object using the configuration
* @internal
*/
public function resolveFile(File|null $file): File|null
{
// shortcut for files that don't exist
if ($file === null) {
return null;
}
$option = $this->option('content.fileRedirects', true);
if ($option === true) {
return $file;
}
if ($option instanceof Closure) {
return $option($file) === true ? $file : null;
}
// option was set to `false` or an invalid value
return null;
2022-08-31 15:02:43 +02:00
}
/**
* Response configuration
*/
2025-04-21 18:57:21 +02:00
public function response(): Responder
2022-08-31 15:02:43 +02:00
{
2022-12-19 14:56:05 +01:00
return $this->response ??= new Responder();
2022-08-31 15:02:43 +02:00
}
/**
* Returns all user roles
*/
2025-04-21 18:57:21 +02:00
public function roles(): Roles
2022-08-31 15:02:43 +02:00
{
2022-12-19 14:56:05 +01:00
return $this->roles ??= Roles::load($this->root('roles'));
2022-08-31 15:02:43 +02:00
}
/**
* Returns a system root
*/
2022-12-19 14:56:05 +01:00
public function root(string $type = 'index'): string|null
2022-08-31 15:02:43 +02:00
{
return $this->roots->__get($type);
}
/**
* Returns the directory structure
*/
2025-04-21 18:57:21 +02:00
public function roots(): Ingredients
2022-08-31 15:02:43 +02:00
{
return $this->roots;
}
/**
* Returns the currently active route
*/
2025-04-21 18:57:21 +02:00
public function route(): Route|null
2022-08-31 15:02:43 +02:00
{
return $this->router()->route();
}
/**
* Returns the Router singleton
*
* @internal
*/
2025-04-21 18:57:21 +02:00
public function router(): Router
2022-08-31 15:02:43 +02:00
{
2025-04-21 18:57:21 +02:00
if ($this->router !== null) {
return $this->router;
}
2022-08-31 15:02:43 +02:00
$routes = $this->routes();
if ($this->multilang() === true) {
foreach ($routes as $index => $route) {
if (empty($route['language']) === false) {
unset($routes[$index]);
}
}
}
$hooks = [
'beforeEach' => function ($route, $path, $method) {
$this->trigger('route:before', compact('route', 'path', 'method'));
},
'afterEach' => function ($route, $path, $method, $result, $final) {
return $this->apply('route:after', compact('route', 'path', 'method', 'result', 'final'), 'result');
}
];
2025-04-21 18:57:21 +02:00
return $this->router = new Router($routes, $hooks);
2022-08-31 15:02:43 +02:00
}
/**
* Returns all defined routes
*
* @internal
*/
public function routes(): array
{
if (is_array($this->routes) === true) {
return $this->routes;
}
$registry = $this->extensions('routes');
$system = $this->core->routes();
$routes = array_merge($system['before'], $registry, $system['after']);
return $this->routes = $routes;
}
/**
* Returns the current session object
*
* @param array $options Additional options, see the session component
*/
2025-04-21 18:57:21 +02:00
public function session(array $options = []): Session
2022-08-31 15:02:43 +02:00
{
$session = $this->sessionHandler()->get($options);
// disable caching for sessions that use the `Authorization` header;
// cookie sessions are already covered by the `Cookie` class
if ($session->mode() === 'manual') {
$this->response()->cache(false);
$this->response()->header('Cache-Control', 'no-store, private', true);
}
return $session;
}
/**
* Returns the session handler
*/
2025-04-21 18:57:21 +02:00
public function sessionHandler(): AutoSession
2022-08-31 15:02:43 +02:00
{
2025-04-21 18:57:21 +02:00
return $this->sessionHandler ??= new AutoSession(
($this->component('session::store'))($this),
$this->option('session', [])
);
}
/**
* Load and set the current language if it exists
* Otherwise fall back to the default language
*
* @internal
*/
public function setCurrentLanguage(
string|null $languageCode = null
): Language|null {
if ($this->multilang() === false) {
Locale::set($this->option('locale', 'en_US.utf-8'));
return $this->language = null;
}
$this->language = $this->language($languageCode) ?? $this->defaultLanguage();
Locale::set($this->language->locale());
// add language slug rules to Str class
Str::$language = $this->language->rules();
return $this->language;
2022-08-31 15:02:43 +02:00
}
/**
* Create your own set of languages
*
* @return $this
*/
2025-04-21 18:57:21 +02:00
protected function setLanguages(array|null $languages = null): static
2022-08-31 15:02:43 +02:00
{
if ($languages !== null) {
$objects = [];
foreach ($languages as $props) {
$objects[] = new Language($props);
}
$this->languages = new Languages($objects);
}
return $this;
}
/**
* Sets the request path that is
* used for the router
*
* @return $this
*/
2025-04-21 18:57:21 +02:00
protected function setPath(string|null $path = null): static
2022-08-31 15:02:43 +02:00
{
$this->path = $path !== null ? trim($path, '/') : null;
return $this;
}
/**
* Sets the request
*
* @return $this
*/
2025-04-21 18:57:21 +02:00
protected function setRequest(array|null $request = null): static
2022-08-31 15:02:43 +02:00
{
if ($request !== null) {
$this->request = new Request($request);
}
return $this;
}
/**
* Create your own set of roles
*
* @return $this
*/
2025-04-21 18:57:21 +02:00
protected function setRoles(array|null $roles = null): static
2022-08-31 15:02:43 +02:00
{
if ($roles !== null) {
2025-04-21 18:57:21 +02:00
$this->roles = Roles::factory($roles);
2022-08-31 15:02:43 +02:00
}
return $this;
}
/**
* Sets a custom Site object
*
* @return $this
*/
2025-04-21 18:57:21 +02:00
protected function setSite(Site|array|null $site = null): static
2022-08-31 15:02:43 +02:00
{
if (is_array($site) === true) {
2025-04-21 18:57:21 +02:00
$site = new Site($site);
2022-08-31 15:02:43 +02:00
}
$this->site = $site;
return $this;
}
/**
* Initializes and returns the Site object
*/
2025-04-21 18:57:21 +02:00
public function site(): Site
2022-08-31 15:02:43 +02:00
{
2022-12-19 14:56:05 +01:00
return $this->site ??= new Site([
2022-08-31 15:02:43 +02:00
'errorPageId' => $this->options['error'] ?? 'error',
'homePageId' => $this->options['home'] ?? 'home',
'url' => $this->url('index'),
]);
}
/**
* Applies the smartypants rule on the text
*
* @internal
*/
2025-04-21 18:57:21 +02:00
public function smartypants(string|null $text = null): string
2022-08-31 15:02:43 +02:00
{
$options = $this->option('smartypants', []);
if ($options === false) {
return $text;
2022-12-19 14:56:05 +01:00
}
if (is_array($options) === false) {
2022-08-31 15:02:43 +02:00
$options = [];
}
if ($this->multilang() === true) {
$languageSmartypants = $this->language()->smartypants() ?? [];
if (empty($languageSmartypants) === false) {
$options = array_merge($options, $languageSmartypants);
}
}
return ($this->component('smartypants'))($this, $text, $options);
}
/**
* Uses the snippet component to create
* and return a template snippet
*
* @param array|object $data Variables or an object that becomes `$item`
* @param bool $return On `false`, directly echo the snippet
2022-12-19 14:56:05 +01:00
* @psalm-return ($return is true ? string : null)
2022-08-31 15:02:43 +02:00
*/
2025-04-21 18:57:21 +02:00
public function snippet(
string|array|null $name,
array|object $data = [],
bool $return = true,
bool $slots = false
): Snippet|string|null {
2022-08-31 15:02:43 +02:00
if (is_object($data) === true) {
$data = ['item' => $data];
}
2023-04-14 16:34:06 +02:00
$snippet = ($this->component('snippet'))(
$this,
$name,
array_merge($this->data, $data),
$slots
);
2022-08-31 15:02:43 +02:00
2023-04-14 16:34:06 +02:00
if ($return === true || $slots === true) {
2022-08-31 15:02:43 +02:00
return $snippet;
}
echo $snippet;
return null;
}
/**
* System check class
*/
2025-04-21 18:57:21 +02:00
public function system(): System
2022-08-31 15:02:43 +02:00
{
2022-12-19 14:56:05 +01:00
return $this->system ??= new System($this);
2022-08-31 15:02:43 +02:00
}
/**
* Uses the template component to initialize
* and return the Template object
*
* @internal
*/
2025-04-21 18:57:21 +02:00
public function template(
string $name,
string $type = 'html',
string $defaultType = 'html'
): Template {
2022-08-31 15:02:43 +02:00
return ($this->component('template'))($this, $name, $type, $defaultType);
}
/**
* Thumbnail creator
*/
public function thumb(string $src, string $dst, array $options = []): string
{
return ($this->component('thumb'))($this, $src, $dst, $options);
}
/**
* Trigger a hook by name
*
* @param string $name Full event name
* @param array $args Associative array of named event arguments
* @param \Kirby\Cms\Event|null $originalEvent Event object (internal use)
*/
2025-04-21 18:57:21 +02:00
public function trigger(
string $name,
array $args = [],
Event|null $originalEvent = null
): void {
2022-08-31 15:02:43 +02:00
$event = $originalEvent ?? new Event($name, $args);
if ($functions = $this->extension('hooks', $name)) {
static $level = 0;
static $triggered = [];
$level++;
foreach ($functions as $index => $function) {
if (in_array($function, $triggered[$name] ?? []) === true) {
continue;
}
// mark the hook as triggered, to avoid endless loops
$triggered[$name][] = $function;
// bind the App object to the hook
$event->call($this, $function);
}
$level--;
if ($level === 0) {
$triggered = [];
}
}
// trigger wildcard hooks if available
$nameWildcards = $event->nameWildcards();
if ($originalEvent === null && count($nameWildcards) > 0) {
foreach ($nameWildcards as $nameWildcard) {
$this->trigger($nameWildcard, $args, $event);
}
}
}
/**
* Returns a system url
*
* @param bool $object If set to `true`, the URL is converted to an object
2022-12-19 14:56:05 +01:00
* @psalm-return ($object is false ? string|null : \Kirby\Http\Uri)
2022-08-31 15:02:43 +02:00
*/
2025-04-21 18:57:21 +02:00
public function url(
string $type = 'index',
bool $object = false
): string|Uri|null {
2022-08-31 15:02:43 +02:00
$url = $this->urls->__get($type);
if ($object === true) {
if (Url::isAbsolute($url)) {
return Url::toObject($url);
}
// index URL was configured without host, use the current host
return Uri::current([
'path' => $url,
'query' => null
]);
}
return $url;
}
/**
* Returns the url structure
*/
2025-04-21 18:57:21 +02:00
public function urls(): Ingredients
2022-08-31 15:02:43 +02:00
{
return $this->urls;
}
/**
* Returns the current version number from
* the composer.json (Keep that up to date! :))
*
* @throws \Kirby\Exception\LogicException if the Kirby version cannot be detected
*/
2022-12-19 14:56:05 +01:00
public static function version(): string|null
2022-08-31 15:02:43 +02:00
{
try {
2022-12-19 14:56:05 +01:00
return static::$version ??= Data::read(dirname(__DIR__, 2) . '/composer.json')['version'] ?? null;
} catch (Throwable) {
2022-08-31 15:02:43 +02:00
throw new LogicException('The Kirby version cannot be detected. The composer.json is probably missing or not readable.');
}
}
/**
* Creates a hash of the version number
*/
public static function versionHash(): string
{
return md5(static::version());
}
/**
* Returns the visitor object
*/
2025-04-21 18:57:21 +02:00
public function visitor(): Visitor
2022-08-31 15:02:43 +02:00
{
2022-12-19 14:56:05 +01:00
return $this->visitor ??= new Visitor();
2022-08-31 15:02:43 +02:00
}
2022-06-17 17:51:59 +02:00
}