Initial commit

This commit is contained in:
Paul Nicoué 2021-10-29 18:05:46 +02:00
commit 1ff19bf38f
830 changed files with 159212 additions and 0 deletions

718
kirby/src/Toolkit/A.php Normal file
View file

@ -0,0 +1,718 @@
<?php
namespace Kirby\Toolkit;
use Exception;
/**
* The `A` class provides a set of handy methods
* to simplify array handling and make it more
* consistent. The class contains methods for
* fetching elements from arrays, merging and
* sorting or shuffling arrays.
*
* @package Kirby Toolkit
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier GmbH
* @license https://opensource.org/licenses/MIT
*/
class A
{
/**
* Appends the given array
*
* @param array $array
* @param array $append
* @return array
*/
public static function append(array $array, array $append): array
{
return $array + $append;
}
/**
* Recursively loops through the array and
* resolves any item defined as `Closure`,
* applying the passed parameters
* @since 3.5.6
*
* @param array $array
* @param mixed ...$args Parameters to pass to the closures
* @return array
*/
public static function apply(array $array, ...$args): array
{
array_walk_recursive($array, function (&$item) use ($args) {
if (is_a($item, 'Closure')) {
$item = $item(...$args);
}
});
return $array;
}
/**
* Gets an element of an array by key
*
* <code>
* $array = [
* 'cat' => 'miao',
* 'dog' => 'wuff',
* 'bird' => 'tweet'
* ];
*
* echo A::get($array, 'cat');
* // output: 'miao'
*
* echo A::get($array, 'elephant', 'shut up');
* // output: 'shut up'
*
* $catAndDog = A::get($array, ['cat', 'dog']);
* // result: ['cat' => 'miao', 'dog' => 'wuff'];
* </code>
*
* @param array $array The source array
* @param mixed $key The key to look for
* @param mixed $default Optional default value, which should be
* returned if no element has been found
* @return mixed
*/
public static function get($array, $key, $default = null)
{
if (is_array($array) === false) {
return $array;
}
// return the entire array if the key is null
if ($key === null) {
return $array;
}
// get an array of keys
if (is_array($key) === true) {
$result = [];
foreach ($key as $k) {
$result[$k] = static::get($array, $k, $default);
}
return $result;
}
if (isset($array[$key]) === true) {
return $array[$key];
}
// extract data from nested array structures using the dot notation
if (strpos($key, '.') !== false) {
$keys = explode('.', $key);
$firstKey = array_shift($keys);
// if the input array also uses dot notation, try to find a subset of the $keys
if (isset($array[$firstKey]) === false) {
$currentKey = $firstKey;
while ($innerKey = array_shift($keys)) {
$currentKey .= '.' . $innerKey;
// the element needs to exist and also needs to be an array; otherwise
// we cannot find the remaining keys within it (invalid array structure)
if (isset($array[$currentKey]) === true && is_array($array[$currentKey]) === true) {
// $keys only holds the remaining keys that have not been shifted off yet
return static::get($array[$currentKey], implode('.', $keys), $default);
}
}
// searching through the full chain of keys wasn't successful
return $default;
}
// if the input array uses a completely nested structure,
// recursively progress layer by layer
if (is_array($array[$firstKey]) === true) {
return static::get($array[$firstKey], implode('.', $keys), $default);
}
// the $firstKey element was found, but isn't an array, so we cannot
// find the remaining keys within it (invalid array structure)
return $default;
}
return $default;
}
/**
* @param mixed $value
* @param mixed $separator
* @return string
*/
public static function join($value, $separator = ', ')
{
if (is_string($value) === true) {
return $value;
}
return implode($separator, $value);
}
const MERGE_OVERWRITE = 0;
const MERGE_APPEND = 1;
const MERGE_REPLACE = 2;
/**
* Merges arrays recursively
*
* @param array $array1
* @param array $array2
* @param int $mode Behavior for elements with numeric keys;
* A::MERGE_APPEND: elements are appended, keys are reset;
* A::MERGE_OVERWRITE: elements are overwritten, keys are preserved
* A::MERGE_REPLACE: non-associative arrays are completely replaced
* @return array
*/
public static function merge($array1, $array2, int $mode = A::MERGE_APPEND)
{
$merged = $array1;
if (static::isAssociative($array1) === false && $mode === static::MERGE_REPLACE) {
return $array2;
}
foreach ($array2 as $key => $value) {
// append to the merged array, don't overwrite numeric keys
if (is_int($key) === true && $mode === static::MERGE_APPEND) {
$merged[] = $value;
// recursively merge the two array values
} elseif (is_array($value) === true && isset($merged[$key]) === true && is_array($merged[$key]) === true) {
$merged[$key] = static::merge($merged[$key], $value, $mode);
// simply overwrite with the value from the second array
} else {
$merged[$key] = $value;
}
}
if ($mode === static::MERGE_APPEND) {
// the keys don't make sense anymore, reset them
// array_merge() is the simplest way to renumber
// arrays that have both numeric and string keys;
// besides the keys, nothing changes here
$merged = array_merge($merged, []);
}
return $merged;
}
/**
* Plucks a single column from an array
*
* <code>
* $array[] = [
* 'id' => 1,
* 'username' => 'homer',
* ];
*
* $array[] = [
* 'id' => 2,
* 'username' => 'marge',
* ];
*
* $array[] = [
* 'id' => 3,
* 'username' => 'lisa',
* ];
*
* var_dump(A::pluck($array, 'username'));
* // result: ['homer', 'marge', 'lisa'];
* </code>
*
* @param array $array The source array
* @param string $key The key name of the column to extract
* @return array The result array with all values
* from that column.
*/
public static function pluck(array $array, string $key)
{
$output = [];
foreach ($array as $a) {
if (isset($a[$key]) === true) {
$output[] = $a[$key];
}
}
return $output;
}
/**
* Prepends the given array
*
* @param array $array
* @param array $prepend
* @return array
*/
public static function prepend(array $array, array $prepend): array
{
return $prepend + $array;
}
/**
* Shuffles an array and keeps the keys
*
* <code>
* $array = [
* 'cat' => 'miao',
* 'dog' => 'wuff',
* 'bird' => 'tweet'
* ];
*
* $shuffled = A::shuffle($array);
* // output: [
* // 'dog' => 'wuff',
* // 'cat' => 'miao',
* // 'bird' => 'tweet'
* // ];
* </code>
*
* @param array $array The source array
* @return array The shuffled result array
*/
public static function shuffle(array $array): array
{
$keys = array_keys($array);
$new = [];
shuffle($keys);
// resort the array
foreach ($keys as $key) {
$new[$key] = $array[$key];
}
return $new;
}
/**
* Returns the first element of an array
*
* <code>
* $array = [
* 'cat' => 'miao',
* 'dog' => 'wuff',
* 'bird' => 'tweet'
* ];
*
* $first = A::first($array);
* // first: 'miao'
* </code>
*
* @param array $array The source array
* @return mixed The first element
*/
public static function first(array $array)
{
return array_shift($array);
}
/**
* Returns the last element of an array
*
* <code>
* $array = [
* 'cat' => 'miao',
* 'dog' => 'wuff',
* 'bird' => 'tweet'
* ];
*
* $last = A::last($array);
* // last: 'tweet'
* </code>
*
* @param array $array The source array
* @return mixed The last element
*/
public static function last(array $array)
{
return array_pop($array);
}
/**
* Fills an array up with additional elements to certain amount.
*
* <code>
* $array = [
* 'cat' => 'miao',
* 'dog' => 'wuff',
* 'bird' => 'tweet'
* ];
*
* $result = A::fill($array, 5, 'elephant');
*
* // result: [
* // 'cat',
* // 'dog',
* // 'bird',
* // 'elephant',
* // 'elephant',
* // ];
* </code>
*
* @param array $array The source array
* @param int $limit The number of elements the array should
* contain after filling it up.
* @param mixed $fill The element, which should be used to
* fill the array
* @return array The filled-up result array
*/
public static function fill(array $array, int $limit, $fill = 'placeholder'): array
{
if (count($array) < $limit) {
$diff = $limit - count($array);
for ($x = 0; $x < $diff; $x++) {
$array[] = $fill;
}
}
return $array;
}
/**
* Move an array item to a new index
*
* @param array $array
* @param int $from
* @param int $to
* @return array
*/
public static function move(array $array, int $from, int $to): array
{
$total = count($array);
if ($from >= $total || $from < 0) {
throw new Exception('Invalid "from" index');
}
if ($to >= $total || $to < 0) {
throw new Exception('Invalid "to" index');
}
// remove the item from the array
$item = array_splice($array, $from, 1);
// inject it at the new position
array_splice($array, $to, 0, $item);
return $array;
}
/**
* Checks for missing elements in an array
*
* This is very handy to check for missing
* user values in a request for example.
*
* <code>
* $array = [
* 'cat' => 'miao',
* 'dog' => 'wuff',
* 'bird' => 'tweet'
* ];
*
* $required = ['cat', 'elephant'];
*
* $missng = A::missing($array, $required);
* // missing: [
* // 'elephant'
* // ];
* </code>
*
* @param array $array The source array
* @param array $required An array of required keys
* @return array An array of missing fields. If this
* is empty, nothing is missing.
*/
public static function missing(array $array, array $required = []): array
{
$missing = [];
foreach ($required as $r) {
if (isset($array[$r]) === false) {
$missing[] = $r;
}
}
return $missing;
}
/**
* Normalizes an array into a nested form by converting
* dot notation in keys to nested structures
*
* @param array $array
* @param array $ignore List of keys in dot notation that should
* not be converted to a nested structure
* @return array
*/
public static function nest(array $array, array $ignore = []): array
{
// convert a simple ignore list to a nested $key => true array
if (isset($ignore[0]) === true) {
$ignore = array_map(function () {
return true;
}, array_flip($ignore));
$ignore = A::nest($ignore);
}
$result = [];
foreach ($array as $fullKey => $value) {
// extract the first part of a multi-level key, keep the others
$subKeys = explode('.', $fullKey);
$key = array_shift($subKeys);
// skip the magic for ignored keys
if (isset($ignore[$key]) === true && $ignore[$key] === true) {
$result[$fullKey] = $value;
continue;
}
// untangle elements where the key uses dot notation
if (count($subKeys) > 0) {
$value = static::nestByKeys($value, $subKeys);
}
// now recursively do the same for each array level if needed
if (is_array($value) === true) {
$value = static::nest($value, $ignore[$key] ?? []);
}
// merge arrays with previous results if necessary
// (needed when the same keys are used both with and without dot notation)
if (
isset($result[$key]) === true &&
is_array($result[$key]) === true &&
is_array($value) === true
) {
$result[$key] = array_replace_recursive($result[$key], $value);
} else {
$result[$key] = $value;
}
}
return $result;
}
/**
* Recursively creates a nested array from a set of keys
* with a key on each level
*
* @param mixed $value Arbitrary value that will end up at the bottom of the tree
* @param array $keys List of keys to use sorted from the topmost level
* @return array|mixed Nested array or (if `$keys` is empty) the input `$value`
*/
public static function nestByKeys($value, array $keys)
{
// shift off the first key from the list
$firstKey = array_shift($keys);
// stop further recursion if there are no more keys
if ($firstKey === null) {
return $value;
}
// return one level of the output tree, recurse further
return [
$firstKey => static::nestByKeys($value, $keys)
];
}
/**
* Sorts a multi-dimensional array by a certain column
*
* <code>
* $array[0] = [
* 'id' => 1,
* 'username' => 'mike',
* ];
*
* $array[1] = [
* 'id' => 2,
* 'username' => 'peter',
* ];
*
* $array[3] = [
* 'id' => 3,
* 'username' => 'john',
* ];
*
* $sorted = A::sort($array, 'username ASC');
* // Array
* // (
* // [0] => Array
* // (
* // [id] => 3
* // [username] => john
* // )
* // [1] => Array
* // (
* // [id] => 1
* // [username] => mike
* // )
* // [2] => Array
* // (
* // [id] => 2
* // [username] => peter
* // )
* // )
*
* </code>
*
* @param array $array The source array
* @param string $field The name of the column
* @param string $direction desc (descending) or asc (ascending)
* @param int $method A PHP sort method flag or 'natural' for
* natural sorting, which is not supported in
* PHP by sort flags
* @return array The sorted array
*/
public static function sort(array $array, string $field, string $direction = 'desc', $method = SORT_REGULAR): array
{
$direction = strtolower($direction) === 'desc' ? SORT_DESC : SORT_ASC;
$helper = [];
$result = [];
// build the helper array
foreach ($array as $key => $row) {
$helper[$key] = $row[$field];
}
// natural sorting
if ($direction === SORT_DESC) {
arsort($helper, $method);
} else {
asort($helper, $method);
}
// rebuild the original array
foreach ($helper as $key => $val) {
$result[$key] = $array[$key];
}
return $result;
}
/**
* Checks wether an array is associative or not
*
* <code>
* $array = ['a', 'b', 'c'];
*
* A::isAssociative($array);
* // returns: false
*
* $array = ['a' => 'a', 'b' => 'b', 'c' => 'c'];
*
* A::isAssociative($array);
* // returns: true
* </code>
*
* @param array $array The array to analyze
* @return bool true: The array is associative false: It's not
*/
public static function isAssociative(array $array): bool
{
return ctype_digit(implode('', array_keys($array))) === false;
}
/**
* Returns the average value of an array
*
* @param array $array The source array
* @param int $decimals The number of decimals to return
* @return float The average value
*/
public static function average(array $array, int $decimals = 0): float
{
return round((array_sum($array) / sizeof($array)), $decimals);
}
/**
* Merges arrays recursively
*
* <code>
* $defaults = [
* 'username' => 'admin',
* 'password' => 'admin',
* ];
*
* $options = A::extend($defaults, ['password' => 'super-secret']);
* // returns: [
* // 'username' => 'admin',
* // 'password' => 'super-secret'
* // ];
* </code>
*
* @param array ...$arrays
* @return array
*/
public static function extend(...$arrays): array
{
return array_merge_recursive(...$arrays);
}
/**
* Update an array with a second array
* The second array can contain callbacks as values,
* which will get the original values as argument
*
* <code>
* $user = [
* 'username' => 'homer',
* 'email' => 'homer@simpsons.com'
* ];
*
* // simple updates
* A::update($user, [
* 'username' => 'homer j. simpson'
* ]);
*
* // with callback
* A::update($user, [
* 'username' => function ($username) {
* return $username . ' j. simpson'
* }
* ]);
* </code>
*
* @param array $array
* @param array $update
* @return array
*/
public static function update(array $array, array $update): array
{
foreach ($update as $key => $value) {
if (is_a($value, 'Closure') === true) {
$array[$key] = call_user_func($value, static::get($array, $key));
} else {
$array[$key] = $value;
}
}
return $array;
}
/**
* Wraps the given value in an array
* if it's not an array yet.
*
* @param mixed|null $array
* @return array
*/
public static function wrap($array = null): array
{
if ($array === null) {
return [];
} elseif (is_array($array) === false) {
return [$array];
} else {
return $array;
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,290 @@
<?php
namespace Kirby\Toolkit;
use ArgumentCountError;
use Kirby\Exception\Exception;
use Kirby\Exception\InvalidArgumentException;
use TypeError;
/**
* Vue-like components
*
* @package Kirby Toolkit
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier GmbH
* @license https://opensource.org/licenses/MIT
*/
class Component
{
/**
* Registry for all component mixins
*
* @var array
*/
public static $mixins = [];
/**
* Registry for all component types
*
* @var array
*/
public static $types = [];
/**
* An array of all passed attributes
*
* @var array
*/
protected $attrs = [];
/**
* An array of all computed properties
*
* @var array
*/
protected $computed = [];
/**
* An array of all registered methods
*
* @var array
*/
protected $methods = [];
/**
* An array of all component options
* from the component definition
*
* @var array
*/
protected $options = [];
/**
* An array of all resolved props
*
* @var array
*/
protected $props = [];
/**
* The component type
*
* @var string
*/
protected $type;
/**
* Magic caller for defined methods and properties
*
* @param string $name
* @param array $arguments
* @return mixed
*/
public function __call(string $name, array $arguments = [])
{
if (array_key_exists($name, $this->computed) === true) {
return $this->computed[$name];
}
if (array_key_exists($name, $this->props) === true) {
return $this->props[$name];
}
if (array_key_exists($name, $this->methods) === true) {
return $this->methods[$name]->call($this, ...$arguments);
}
return $this->$name;
}
/**
* Creates a new component for the given type
*
* @param string $type
* @param array $attrs
*/
public function __construct(string $type, array $attrs = [])
{
if (isset(static::$types[$type]) === false) {
throw new InvalidArgumentException('Undefined component type: ' . $type);
}
$this->attrs = $attrs;
$this->options = $options = $this->setup($type);
$this->methods = $methods = $options['methods'] ?? [];
foreach ($attrs as $attrName => $attrValue) {
$this->$attrName = $attrValue;
}
if (isset($options['props']) === true) {
$this->applyProps($options['props']);
}
if (isset($options['computed']) === true) {
$this->applyComputed($options['computed']);
}
$this->attrs = $attrs;
$this->methods = $methods;
$this->options = $options;
$this->type = $type;
}
/**
* Improved `var_dump` output
*
* @return array
*/
public function __debugInfo(): array
{
return $this->toArray();
}
/**
* Fallback for missing properties to return
* null instead of an error
*
* @param string $attr
* @return null
*/
public function __get(string $attr)
{
return null;
}
/**
* A set of default options for each component.
* This can be overwritten by extended classes
* to define basic options that should always
* be applied.
*
* @return array
*/
public static function defaults(): array
{
return [];
}
/**
* Register all defined props and apply the
* passed values.
*
* @param array $props
* @return void
*/
protected function applyProps(array $props): void
{
foreach ($props as $propName => $propFunction) {
if (is_a($propFunction, 'Closure') === true) {
if (isset($this->attrs[$propName]) === true) {
try {
$this->$propName = $this->props[$propName] = $propFunction->call($this, $this->attrs[$propName]);
} catch (TypeError $e) {
throw new TypeError('Invalid value for "' . $propName . '"');
}
} else {
try {
$this->$propName = $this->props[$propName] = $propFunction->call($this);
} catch (ArgumentCountError $e) {
throw new ArgumentCountError('Please provide a value for "' . $propName . '"');
}
}
} else {
$this->$propName = $this->props[$propName] = $propFunction;
}
}
}
/**
* Register all computed properties and calculate their values.
* This must happen after all props are registered.
*
* @param array $computed
* @return void
*/
protected function applyComputed(array $computed): void
{
foreach ($computed as $computedName => $computedFunction) {
if (is_a($computedFunction, 'Closure') === true) {
$this->$computedName = $this->computed[$computedName] = $computedFunction->call($this);
}
}
}
/**
* Load a component definition by type
*
* @param string $type
* @return array
*/
public static function load(string $type): array
{
$definition = static::$types[$type];
// load definitions from string
if (is_string($definition) === true) {
if (is_file($definition) !== true) {
throw new Exception('Component definition ' . $definition . ' does not exist');
}
static::$types[$type] = $definition = F::load($definition);
}
return $definition;
}
/**
* Loads all options from the component definition
* mixes in the defaults from the defaults method and
* then injects all additional mixins, defined in the
* component options.
*
* @param string $type
* @return array
*/
public static function setup(string $type): array
{
// load component definition
$definition = static::load($type);
if (isset($definition['extends']) === true) {
// extend other definitions
$options = array_replace_recursive(static::defaults(), static::load($definition['extends']), $definition);
} else {
// inject defaults
$options = array_replace_recursive(static::defaults(), $definition);
}
// inject mixins
if (isset($options['mixins']) === true) {
foreach ($options['mixins'] as $mixin) {
if (isset(static::$mixins[$mixin]) === true) {
$options = array_replace_recursive(static::$mixins[$mixin], $options);
}
}
}
return $options;
}
/**
* Converts all props and computed props to an array
*
* @return array
*/
public function toArray(): array
{
if (is_a($this->options['toArray'] ?? null, 'Closure') === true) {
return $this->options['toArray']->call($this);
}
$array = array_merge($this->attrs, $this->props, $this->computed);
ksort($array);
return $array;
}
}

View file

@ -0,0 +1,21 @@
<?php
namespace Kirby\Toolkit;
/**
* This is the core class to handle
* configuration values/constants.
*
* @package Kirby Toolkit
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier GmbH
* @license https://opensource.org/licenses/MIT
*/
class Config extends Silo
{
/**
* @var array
*/
public static $data = [];
}

View file

@ -0,0 +1,66 @@
<?php
namespace Kirby\Toolkit;
use Closure;
use ReflectionFunction;
/**
* A smart extension of Closures with
* magic dependency injection based on the
* defined variable names.
*
* @package Kirby Toolkit
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier GmbH
* @license https://opensource.org/licenses/MIT
*/
class Controller
{
protected $function;
public function __construct(Closure $function)
{
$this->function = $function;
}
public function arguments(array $data = []): array
{
$info = new ReflectionFunction($this->function);
$args = [];
foreach ($info->getParameters() as $parameter) {
$name = $parameter->getName();
$args[] = $data[$name] ?? null;
}
return $args;
}
public function call($bind = null, $data = [])
{
$args = $this->arguments($data);
if ($bind === null) {
return call_user_func($this->function, ...$args);
}
return $this->function->call($bind, ...$args);
}
public static function load(string $file)
{
if (is_file($file) === false) {
return null;
}
$function = F::load($file);
if (is_a($function, 'Closure') === false) {
return null;
}
return new static($function);
}
}

444
kirby/src/Toolkit/Dir.php Normal file
View file

@ -0,0 +1,444 @@
<?php
namespace Kirby\Toolkit;
use Exception;
use Throwable;
/**
* The `Dir` class provides methods
* for dealing with directories on the
* file system level, like creating,
* listing, moving, copying or
* evaluating directories etc.
*
* @package Kirby Toolkit
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier GmbH
* @license https://opensource.org/licenses/MIT
*/
class Dir
{
/**
* Ignore when scanning directories
*
* @var array
*/
public static $ignore = [
'.',
'..',
'.DS_Store',
'.gitignore',
'.git',
'.svn',
'.htaccess',
'Thumb.db',
'@eaDir'
];
/**
* Copy the directory to a new destination
*
* @param string $dir
* @param string $target
* @param bool $recursive
* @param array $ignore
* @return bool
*/
public static function copy(string $dir, string $target, bool $recursive = true, array $ignore = []): bool
{
if (is_dir($dir) === false) {
throw new Exception('The directory "' . $dir . '" does not exist');
}
if (is_dir($target) === true) {
throw new Exception('The target directory "' . $target . '" exists');
}
if (static::make($target) !== true) {
throw new Exception('The target directory "' . $target . '" could not be created');
}
foreach (static::read($dir) as $name) {
$root = $dir . '/' . $name;
if (in_array($root, $ignore) === true) {
continue;
}
if (is_dir($root) === true) {
if ($recursive === true) {
static::copy($root, $target . '/' . $name, true, $ignore);
}
} else {
F::copy($root, $target . '/' . $name);
}
}
return true;
}
/**
* Get all subdirectories
*
* @param string $dir
* @param array $ignore
* @param bool $absolute
* @return array
*/
public static function dirs(string $dir, array $ignore = null, bool $absolute = false): array
{
$result = array_values(array_filter(static::read($dir, $ignore, true), 'is_dir'));
if ($absolute !== true) {
$result = array_map('basename', $result);
}
return $result;
}
/**
* Checks if the directory exists on disk
*
* @param string $dir
* @return bool
*/
public static function exists(string $dir): bool
{
return is_dir($dir) === true;
}
/**
* Get all files
*
* @param string $dir
* @param array $ignore
* @param bool $absolute
* @return array
*/
public static function files(string $dir, array $ignore = null, bool $absolute = false): array
{
$result = array_values(array_filter(static::read($dir, $ignore, true), 'is_file'));
if ($absolute !== true) {
$result = array_map('basename', $result);
}
return $result;
}
/**
* Read the directory and all subdirectories
*
* @param string $dir
* @param bool $recursive
* @param array $ignore
* @param string $path
* @return array
*/
public static function index(string $dir, bool $recursive = false, array $ignore = null, string $path = null)
{
$result = [];
$dir = realpath($dir);
$items = static::read($dir);
foreach ($items as $item) {
$root = $dir . '/' . $item;
$entry = $path !== null ? $path . '/' . $item: $item;
$result[] = $entry;
if ($recursive === true && is_dir($root) === true) {
$result = array_merge($result, static::index($root, true, $ignore, $entry));
}
}
return $result;
}
/**
* Checks if the folder has any contents
*
* @param string $dir
* @return bool
*/
public static function isEmpty(string $dir): bool
{
return count(static::read($dir)) === 0;
}
/**
* Checks if the directory is readable
*
* @param string $dir
* @return bool
*/
public static function isReadable(string $dir): bool
{
return is_readable($dir);
}
/**
* Checks if the directory is writable
*
* @param string $dir
* @return bool
*/
public static function isWritable(string $dir): bool
{
return is_writable($dir);
}
/**
* Create a (symbolic) link to a directory
*
* @param string $source
* @param string $link
* @return bool
*/
public static function link(string $source, string $link): bool
{
Dir::make(dirname($link), true);
if (is_dir($link) === true) {
return true;
}
if (is_dir($source) === false) {
throw new Exception(sprintf('The directory "%s" does not exist and cannot be linked', $source));
}
try {
return symlink($source, $link) === true;
} catch (Throwable $e) {
return false;
}
}
/**
* Creates a new directory
*
* @param string $dir The path for the new directory
* @param bool $recursive Create all parent directories, which don't exist
* @return bool True: the dir has been created, false: creating failed
* @throws \Exception If a file with the provided path already exists or the parent directory is not writable
*/
public static function make(string $dir, bool $recursive = true): bool
{
if (empty($dir) === true) {
return false;
}
if (is_dir($dir) === true) {
return true;
}
if (is_file($dir) === true) {
throw new Exception(sprintf('A file with the name "%s" already exists', $dir));
}
$parent = dirname($dir);
if ($recursive === true) {
if (is_dir($parent) === false) {
static::make($parent, true);
}
}
if (is_writable($parent) === false) {
throw new Exception(sprintf('The directory "%s" cannot be created', $dir));
}
return mkdir($dir);
}
/**
* Recursively check when the dir and all
* subfolders have been modified for the last time.
*
* @param string $dir The path of the directory
* @param string $format
* @param string $handler
* @return int|string
*/
public static function modified(string $dir, string $format = null, string $handler = 'date')
{
$modified = filemtime($dir);
$items = static::read($dir);
foreach ($items as $item) {
if (is_file($dir . '/' . $item) === true) {
$newModified = filemtime($dir . '/' . $item);
} else {
$newModified = static::modified($dir . '/' . $item);
}
$modified = ($newModified > $modified) ? $newModified : $modified;
}
return $format !== null ? $handler($format, $modified) : $modified;
}
/**
* Moves a directory to a new location
*
* @param string $old The current path of the directory
* @param string $new The desired path where the dir should be moved to
* @return bool true: the directory has been moved, false: moving failed
*/
public static function move(string $old, string $new): bool
{
if ($old === $new) {
return true;
}
if (is_dir($old) === false || is_dir($new) === true) {
return false;
}
if (static::make(dirname($new), true) !== true) {
throw new Exception('The parent directory cannot be created');
}
return rename($old, $new);
}
/**
* Returns a nicely formatted size of all the contents of the folder
*
* @param string $dir The path of the directory
* @return mixed
*/
public static function niceSize(string $dir)
{
return F::niceSize(static::size($dir));
}
/**
* Reads all files from a directory and returns them as an array.
* It skips unwanted invisible stuff.
*
* @param string $dir The path of directory
* @param array $ignore Optional array with filenames, which should be ignored
* @param bool $absolute If true, the full path for each item will be returned
* @return array An array of filenames
*/
public static function read(string $dir, array $ignore = null, bool $absolute = false): array
{
if (is_dir($dir) === false) {
return [];
}
// create the ignore pattern
$ignore = $ignore ?? static::$ignore;
$ignore = array_merge($ignore, ['.', '..']);
// scan for all files and dirs
$result = array_values((array)array_diff(scandir($dir), $ignore));
// add absolute paths
if ($absolute === true) {
$result = array_map(function ($item) use ($dir) {
return $dir . '/' . $item;
}, $result);
}
return $result;
}
/**
* Removes a folder including all containing files and folders
*
* @param string $dir
* @return bool
*/
public static function remove(string $dir): bool
{
$dir = realpath($dir);
if (is_dir($dir) === false) {
return true;
}
if (is_link($dir) === true) {
return unlink($dir);
}
foreach (scandir($dir) as $childName) {
if (in_array($childName, ['.', '..']) === true) {
continue;
}
$child = $dir . '/' . $childName;
if (is_link($child) === true) {
unlink($child);
} elseif (is_dir($child) === true) {
static::remove($child);
} else {
F::remove($child);
}
}
return rmdir($dir);
}
/**
* Gets the size of the directory and all subfolders and files
*
* @param string $dir The path of the directory
* @return mixed
*/
public static function size(string $dir)
{
if (is_dir($dir) === false) {
return false;
}
$size = 0;
$items = static::read($dir);
foreach ($items as $item) {
$root = $dir . '/' . $item;
if (is_dir($root) === true) {
$size += static::size($root);
} elseif (is_file($root) === true) {
$size += F::size($root);
}
}
return $size;
}
/**
* Checks if the directory or any subdirectory has been
* modified after the given timestamp
*
* @param string $dir
* @param int $time
* @return bool
*/
public static function wasModifiedAfter(string $dir, int $time): bool
{
if (filemtime($dir) > $time) {
return true;
}
$content = static::read($dir);
foreach ($content as $item) {
$subdir = $dir . '/' . $item;
if (filemtime($subdir) > $time) {
return true;
}
if (is_dir($subdir) === true && static::wasModifiedAfter($subdir, $time) === true) {
return true;
}
}
return false;
}
}

View file

@ -0,0 +1,162 @@
<?php
namespace Kirby\Toolkit;
use Laminas\Escaper\Escaper;
/**
* The `Escape` class provides methods
* for escaping common HTML attributes
* data. This can be used to put
* untrusted data into typical
* attribute values like width, name,
* value, etc.
*
* Wrapper for the Laminas Escaper
* @link https://github.com/laminas/laminas-escaper
*
* @package Kirby Toolkit
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier GmbH
* @license https://opensource.org/licenses/MIT
*/
class Escape
{
/**
* The internal singleton escaper instance
*
* @var \Laminas\Escaper\Escaper
*/
protected static $escaper;
/**
* Escape common HTML attributes data
*
* This can be used to put untrusted data into typical attribute values
* like width, name, value, etc.
*
* This should not be used for complex attributes like href, src, style,
* or any of the event handlers like onmouseover.
* Use esc($string, 'js') for event handler attributes, esc($string, 'url')
* for src attributes and esc($string, 'css') for style attributes.
*
* <div attr=...ESCAPE UNTRUSTED DATA BEFORE PUTTING HERE...>content</div>
* <div attr='...ESCAPE UNTRUSTED DATA BEFORE PUTTING HERE...'>content</div>
* <div attr="...ESCAPE UNTRUSTED DATA BEFORE PUTTING HERE...">content</div>
*
* @param string $string
* @return string
*/
public static function attr($string)
{
return static::escaper()->escapeHtmlAttr($string);
}
/**
* Escape HTML style property values
*
* This can be used to put untrusted data into a stylesheet or a style tag.
*
* Stay away from putting untrusted data into complex properties like url,
* behavior, and custom (-moz-binding). You should also not put untrusted data
* into IEs expression property value which allows JavaScript.
*
* <style>selector { property : ...ESCAPE UNTRUSTED DATA BEFORE PUTTING HERE...; } </style>
* <style>selector { property : "...ESCAPE UNTRUSTED DATA BEFORE PUTTING HERE..."; } </style>
* <span style="property : ...ESCAPE UNTRUSTED DATA BEFORE PUTTING HERE...">text</span>
*
* @param string $string
* @return string
*/
public static function css($string)
{
return static::escaper()->escapeCss($string);
}
/**
* Get the escaper instance (and create if needed)
*
* @return \Laminas\Escaper\Escaper
*/
protected static function escaper()
{
return static::$escaper = static::$escaper ?? new Escaper('utf-8');
}
/**
* Escape HTML element content
*
* This can be used to put untrusted data directly into the HTML body somewhere.
* This includes inside normal tags like div, p, b, td, etc.
*
* Escapes &, <, >, ", and ' with HTML entity encoding to prevent switching
* into any execution context, such as script, style, or event handlers.
*
* <body>...ESCAPE UNTRUSTED DATA BEFORE PUTTING HERE...</body>
* <div>...ESCAPE UNTRUSTED DATA BEFORE PUTTING HERE...</div>
*
* @param string $string
* @return string
*/
public static function html($string)
{
return static::escaper()->escapeHtml($string);
}
/**
* Escape JavaScript data values
*
* This can be used to put dynamically generated JavaScript code
* into both script blocks and event-handler attributes.
*
* <script>alert('...ESCAPE UNTRUSTED DATA BEFORE PUTTING HERE...')</script>
* <script>x='...ESCAPE UNTRUSTED DATA BEFORE PUTTING HERE...'</script>
* <div onmouseover="x='...ESCAPE UNTRUSTED DATA BEFORE PUTTING HERE...'"</div>
*
* @param string $string
* @return string
*/
public static function js($string)
{
return static::escaper()->escapeJs($string);
}
/**
* Escape URL parameter values
*
* This can be used to put untrusted data into HTTP GET parameter values.
* This should not be used to escape an entire URI.
*
* <a href="http://www.somesite.com?test=...ESCAPE UNTRUSTED DATA BEFORE PUTTING HERE...">link</a>
*
* @param string $string
* @return string
*/
public static function url($string)
{
return rawurlencode($string);
}
/**
* Escape XML element content
*
* Removes offending characters that could be wrongfully interpreted as XML markup.
*
* The following characters are reserved in XML and will be replaced with their
* corresponding XML entities:
*
* ' is replaced with &apos;
* " is replaced with &quot;
* & is replaced with &amp;
* < is replaced with &lt;
* > is replaced with &gt;
*
* @param string $string
* @return string
*/
public static function xml($string)
{
return htmlspecialchars($string, ENT_QUOTES | ENT_XML1, 'UTF-8');
}
}

865
kirby/src/Toolkit/F.php Normal file
View file

@ -0,0 +1,865 @@
<?php
namespace Kirby\Toolkit;
use Exception;
use Throwable;
use ZipArchive;
/**
* The `F` class provides methods for
* dealing with files on the file system
* level, like creating, reading,
* deleting, copying or validatings files.
*
* @package Kirby Toolkit
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier GmbH
* @license https://opensource.org/licenses/MIT
*/
class F
{
public static $types = [
'archive' => [
'gz',
'gzip',
'tar',
'tgz',
'zip',
],
'audio' => [
'aif',
'aiff',
'm4a',
'midi',
'mp3',
'wav',
],
'code' => [
'css',
'js',
'json',
'java',
'htm',
'html',
'php',
'rb',
'py',
'scss',
'xml',
'yaml',
'yml',
],
'document' => [
'csv',
'doc',
'docx',
'dotx',
'indd',
'md',
'mdown',
'pdf',
'ppt',
'pptx',
'rtf',
'txt',
'xl',
'xls',
'xlsx',
'xltx',
],
'image' => [
'ai',
'avif',
'bmp',
'gif',
'eps',
'ico',
'j2k',
'jp2',
'jpeg',
'jpg',
'jpe',
'png',
'ps',
'psd',
'svg',
'tif',
'tiff',
'webp'
],
'video' => [
'avi',
'flv',
'm4v',
'mov',
'movie',
'mpe',
'mpg',
'mp4',
'ogg',
'ogv',
'swf',
'webm',
],
];
public static $units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
/**
* Appends new content to an existing file
*
* @param string $file The path for the file
* @param mixed $content Either a string or an array. Arrays will be converted to JSON.
* @return bool
*/
public static function append(string $file, $content): bool
{
return static::write($file, $content, true);
}
/**
* Returns the file content as base64 encoded string
*
* @param string $file The path for the file
* @return string
*/
public static function base64(string $file): string
{
return base64_encode(static::read($file));
}
/**
* Copy a file to a new location.
*
* @param string $source
* @param string $target
* @param bool $force
* @return bool
*/
public static function copy(string $source, string $target, bool $force = false): bool
{
if (file_exists($source) === false || (file_exists($target) === true && $force === false)) {
return false;
}
$directory = dirname($target);
// create the parent directory if it does not exist
if (is_dir($directory) === false) {
Dir::make($directory, true);
}
return copy($source, $target);
}
/**
* Just an alternative for dirname() to stay consistent
*
* <code>
*
* $dirname = F::dirname('/var/www/test.txt');
* // dirname is /var/www
*
* </code>
*
* @param string $file The path
* @return string
*/
public static function dirname(string $file): string
{
return dirname($file);
}
/**
* Checks if the file exists on disk
*
* @param string $file
* @param string $in
* @return bool
*/
public static function exists(string $file, string $in = null): bool
{
try {
static::realpath($file, $in);
return true;
} catch (Exception $e) {
return false;
}
}
/**
* Gets the extension of a file
*
* @param string $file The filename or path
* @param string $extension Set an optional extension to overwrite the current one
* @return string
*/
public static function extension(string $file = null, string $extension = null): string
{
// overwrite the current extension
if ($extension !== null) {
return static::name($file) . '.' . $extension;
}
// return the current extension
return Str::lower(pathinfo($file, PATHINFO_EXTENSION));
}
/**
* Converts a file extension to a mime type
*
* @param string $extension
* @return string|false
*/
public static function extensionToMime(string $extension)
{
return Mime::fromExtension($extension);
}
/**
* Returns the file type for a passed extension
*
* @param string $extension
* @return string|false
*/
public static function extensionToType(string $extension)
{
foreach (static::$types as $type => $extensions) {
if (in_array($extension, $extensions) === true) {
return $type;
}
}
return false;
}
/**
* Returns all extensions for a certain file type
*
* @param string $type
* @return array
*/
public static function extensions(string $type = null)
{
if ($type === null) {
return array_keys(Mime::types());
}
return static::$types[$type] ?? [];
}
/**
* Extracts the filename from a file path
*
* <code>
*
* $filename = F::filename('/var/www/test.txt');
* // filename is test.txt
*
* </code>
*
* @param string $name The path
* @return string
*/
public static function filename(string $name): string
{
return pathinfo($name, PATHINFO_BASENAME);
}
/**
* Invalidate opcode cache for file.
*
* @param string $file The path of the file
* @return bool
*/
public static function invalidateOpcodeCache(string $file): bool
{
if (function_exists('opcache_invalidate') && strlen(ini_get('opcache.restrict_api')) === 0) {
return opcache_invalidate($file, true);
} else {
return false;
}
}
/**
* Checks if a file is of a certain type
*
* @param string $file Full path to the file
* @param string $value An extension or mime type
* @return bool
*/
public static function is(string $file, string $value): bool
{
// check for the extension
if (in_array($value, static::extensions()) === true) {
return static::extension($file) === $value;
}
// check for the mime type
if (strpos($value, '/') !== false) {
return static::mime($file) === $value;
}
return false;
}
/**
* Checks if the file is readable
*
* @param string $file
* @return bool
*/
public static function isReadable(string $file): bool
{
return is_readable($file);
}
/**
* Checks if the file is writable
*
* @param string $file
* @return bool
*/
public static function isWritable(string $file): bool
{
if (file_exists($file) === false) {
return is_writable(dirname($file));
}
return is_writable($file);
}
/**
* Create a (symbolic) link to a file
*
* @param string $source
* @param string $link
* @param string $method
* @return bool
*/
public static function link(string $source, string $link, string $method = 'link'): bool
{
Dir::make(dirname($link), true);
if (is_file($link) === true) {
return true;
}
if (is_file($source) === false) {
throw new Exception(sprintf('The file "%s" does not exist and cannot be linked', $source));
}
try {
return $method($source, $link) === true;
} catch (Throwable $e) {
return false;
}
}
/**
* Loads a file and returns the result or `false` if the
* file to load does not exist
*
* @param string $file
* @param mixed $fallback
* @param array $data Optional array of variables to extract in the variable scope
* @return mixed
*/
public static function load(string $file, $fallback = null, array $data = [])
{
if (is_file($file) === false) {
return $fallback;
}
// we use the loadIsolated() method here to prevent the included
// file from overwriting our $fallback in this variable scope; see
// https://www.php.net/manual/en/function.include.php#example-124
$result = static::loadIsolated($file, $data);
if ($fallback !== null && gettype($result) !== gettype($fallback)) {
return $fallback;
}
return $result;
}
/**
* Loads a file with as little as possible in the variable scope
*
* @param string $file
* @param array $data Optional array of variables to extract in the variable scope
* @return mixed
*/
protected static function loadIsolated(string $file, array $data = [])
{
// extract the $data variables in this scope to be accessed by the included file;
// protect $file against being overwritten by a $data variable
$___file___ = $file;
extract($data);
return include $___file___;
}
/**
* Loads a file using `include_once()` and returns whether loading was successful
*
* @param string $file
* @return bool
*/
public static function loadOnce(string $file): bool
{
if (is_file($file) === false) {
return false;
}
include_once $file;
return true;
}
/**
* Returns the mime type of a file
*
* @param string $file
* @return string|false
*/
public static function mime(string $file)
{
return Mime::type($file);
}
/**
* Converts a mime type to a file extension
*
* @param string $mime
* @return string|false
*/
public static function mimeToExtension(string $mime = null)
{
return Mime::toExtension($mime);
}
/**
* Returns the type for a given mime
*
* @param string $mime
* @return string|false
*/
public static function mimeToType(string $mime)
{
return static::extensionToType(Mime::toExtension($mime));
}
/**
* Get the file's last modification time.
*
* @param string $file
* @param string $format
* @param string $handler date or strftime
* @return mixed
*/
public static function modified(string $file, string $format = null, string $handler = 'date')
{
if (file_exists($file) !== true) {
return false;
}
$stat = stat($file);
$mtime = $stat['mtime'] ?? 0;
$ctime = $stat['ctime'] ?? 0;
$modified = max([$mtime, $ctime]);
if (is_null($format) === true) {
return $modified;
}
return $handler($format, $modified);
}
/**
* Moves a file to a new location
*
* @param string $oldRoot The current path for the file
* @param string $newRoot The path to the new location
* @param bool $force Force move if the target file exists
* @return bool
*/
public static function move(string $oldRoot, string $newRoot, bool $force = false): bool
{
// check if the file exists
if (file_exists($oldRoot) === false) {
return false;
}
if (file_exists($newRoot) === true) {
if ($force === false) {
return false;
}
// delete the existing file
static::remove($newRoot);
}
// actually move the file if it exists
if (rename($oldRoot, $newRoot) !== true) {
return false;
}
return true;
}
/**
* Extracts the name from a file path or filename without extension
*
* @param string $name The path or filename
* @return string
*/
public static function name(string $name): string
{
return pathinfo($name, PATHINFO_FILENAME);
}
/**
* Converts an integer size into a human readable format
*
* @param mixed $size The file size or a file path
* @param string|null|false $locale Locale for number formatting,
* `null` for the current locale,
* `false` to disable number formatting
* @return string
*/
public static function niceSize($size, $locale = null): string
{
// file mode
if (is_string($size) === true && file_exists($size) === true) {
$size = static::size($size);
}
// make sure it's an int
$size = (int)$size;
// avoid errors for invalid sizes
if ($size <= 0) {
return '0 KB';
}
// the math magic
$size = round($size / pow(1024, ($unit = floor(log($size, 1024)))), 2);
// format the number if requested
if ($locale !== false) {
$size = I18n::formatNumber($size, $locale);
}
return $size . ' ' . static::$units[$unit];
}
/**
* Reads the content of a file or requests the
* contents of a remote HTTP or HTTPS URL
*
* @param string $file The path for the file or an absolute URL
* @return string|false
*/
public static function read(string $file)
{
if (
is_file($file) !== true &&
Str::startsWith($file, 'https://') !== true &&
Str::startsWith($file, 'http://') !== true
) {
return false;
}
return @file_get_contents($file);
}
/**
* Changes the name of the file without
* touching the extension
*
* @param string $file
* @param string $newName
* @param bool $overwrite Force overwrite existing files
* @return string|false
*/
public static function rename(string $file, string $newName, bool $overwrite = false)
{
// create the new name
$name = static::safeName(basename($newName));
// overwrite the root
$newRoot = rtrim(dirname($file) . '/' . $name . '.' . F::extension($file), '.');
// nothing has changed
if ($newRoot === $file) {
return $newRoot;
}
if (F::move($file, $newRoot, $overwrite) !== true) {
return false;
}
return $newRoot;
}
/**
* Returns the absolute path to the file if the file can be found.
*
* @param string $file
* @param string $in
* @return string|null
*/
public static function realpath(string $file, string $in = null)
{
$realpath = realpath($file);
if ($realpath === false || is_file($realpath) === false) {
throw new Exception(sprintf('The file does not exist at the given path: "%s"', $file));
}
if ($in !== null) {
$parent = realpath($in);
if ($parent === false || is_dir($parent) === false) {
throw new Exception(sprintf('The parent directory does not exist: "%s"', $in));
}
if (substr($realpath, 0, strlen($parent)) !== $parent) {
throw new Exception('The file is not within the parent directory');
}
}
return $realpath;
}
/**
* Returns the relative path of the file
* starting after $in
*
* @param string $file
* @param string $in
* @return string
*/
public static function relativepath(string $file, string $in = null): string
{
if (empty($in) === true) {
return basename($file);
}
// windows
$file = str_replace('\\', '/', $file);
$in = str_replace('\\', '/', $in);
if (Str::contains($file, $in) === false) {
return basename($file);
}
return Str::after($file, $in);
}
/**
* Deletes a file
*
* <code>
*
* $remove = F::remove('test.txt');
* if($remove) echo 'The file has been removed';
*
* </code>
*
* @param string $file The path for the file
* @return bool
*/
public static function remove(string $file): bool
{
if (strpos($file, '*') !== false) {
foreach (glob($file) as $f) {
static::remove($f);
}
return true;
}
$file = realpath($file);
if (file_exists($file) === false) {
return true;
}
return unlink($file);
}
/**
* Sanitize a filename to strip unwanted special characters
*
* <code>
*
* $safe = f::safeName('über genious.txt');
* // safe will be ueber-genious.txt
*
* </code>
*
* @param string $string The file name
* @return string
*/
public static function safeName(string $string): string
{
$name = static::name($string);
$extension = static::extension($string);
$safeName = Str::slug($name, '-', 'a-z0-9@._-');
$safeExtension = empty($extension) === false ? '.' . Str::slug($extension) : '';
return $safeName . $safeExtension;
}
/**
* Tries to find similar or the same file by
* building a glob based on the path
*
* @param string $path
* @param string $pattern
* @return array
*/
public static function similar(string $path, string $pattern = '*'): array
{
$dir = dirname($path);
$name = static::name($path);
$extension = static::extension($path);
$glob = $dir . '/' . $name . $pattern . '.' . $extension;
return glob($glob);
}
/**
* Returns the size of a file.
*
* @param mixed $file The path
* @return int
*/
public static function size(string $file): int
{
try {
return filesize($file);
} catch (Throwable $e) {
return 0;
}
}
/**
* Categorize the file
*
* @param string $file Either the file path or extension
* @return string|null
*/
public static function type(string $file)
{
$length = strlen($file);
if ($length >= 2 && $length <= 4) {
// use the file name as extension
$extension = $file;
} else {
// get the extension from the filename
$extension = pathinfo($file, PATHINFO_EXTENSION);
}
if (empty($extension) === true) {
// detect the mime type first to get the most reliable extension
$mime = static::mime($file);
$extension = static::mimeToExtension($mime);
}
// sanitize extension
$extension = strtolower($extension);
foreach (static::$types as $type => $extensions) {
if (in_array($extension, $extensions) === true) {
return $type;
}
}
return null;
}
/**
* Returns all extensions of a given file type
* or `null` if the file type is unknown
*
* @param string $type
* @return array|null
*/
public static function typeToExtensions(string $type): ?array
{
return static::$types[$type] ?? null;
}
/**
* Unzips a zip file
*
* @param string $file
* @param string $to
* @return bool
*/
public static function unzip(string $file, string $to): bool
{
if (class_exists('ZipArchive') === false) {
throw new Exception('The ZipArchive class is not available');
}
$zip = new ZipArchive();
if ($zip->open($file) === true) {
$zip->extractTo($to);
$zip->close();
return true;
}
return false;
}
/**
* Returns the file as data uri
*
* @param string $file The path for the file
* @return string|false
*/
public static function uri(string $file)
{
if ($mime = static::mime($file)) {
return 'data:' . $mime . ';base64,' . static::base64($file);
}
return false;
}
/**
* Creates a new file
*
* @param string $file The path for the new file
* @param mixed $content Either a string, an object or an array. Arrays and objects will be serialized.
* @param bool $append true: append the content to an exisiting file if available. false: overwrite.
* @return bool
*/
public static function write(string $file, $content, bool $append = false): bool
{
if (is_array($content) === true || is_object($content) === true) {
$content = serialize($content);
}
$mode = $append === true ? FILE_APPEND | LOCK_EX : LOCK_EX;
// if the parent directory does not exist, create it
if (is_dir(dirname($file)) === false) {
if (Dir::make(dirname($file)) === false) {
return false;
}
}
if (static::isWritable($file) === false) {
throw new Exception('The file "' . $file . '" is not writable');
}
return file_put_contents($file, $content, $mode) !== false;
}
}

View file

@ -0,0 +1,36 @@
<?php
namespace Kirby\Toolkit;
/**
* Laravel-style static facades
* for class instances
*
* @package Kirby Toolkit
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier GmbH
* @license https://opensource.org/licenses/MIT
*/
abstract class Facade
{
/**
* Returns the instance that should be
* available statically
*
* @return mixed
*/
abstract public static function instance();
/**
* Proxy for all public instance calls
*
* @param string $method
* @param array $args
* @return mixed
*/
public static function __callStatic(string $method, array $args = null)
{
return static::instance()->$method(...$args);
}
}

358
kirby/src/Toolkit/File.php Normal file
View file

@ -0,0 +1,358 @@
<?php
namespace Kirby\Toolkit;
use Exception;
use Kirby\Sane\Sane;
/**
* Flexible File object with a set of helpful
* methods to inspect and work with files.
*
* @package Kirby Toolkit
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier GmbH
* @license https://opensource.org/licenses/MIT
*/
class File
{
/**
* Absolute file path
*
* @var string
*/
protected $root;
/**
* Constructs a new File object by absolute path
*
* @param string $root Absolute file path
*/
public function __construct(string $root = null)
{
$this->root = $root;
}
/**
* Improved `var_dump` output
*
* @return array
*/
public function __debugInfo(): array
{
return $this->toArray();
}
/**
* Returns the file content as base64 encoded string
*
* @return string
*/
public function base64(): string
{
return base64_encode($this->read());
}
/**
* Copy a file to a new location.
*
* @param string $target
* @param bool $force
* @return static
*/
public function copy(string $target, bool $force = false)
{
if (F::copy($this->root, $target, $force) !== true) {
throw new Exception('The file "' . $this->root . '" could not be copied');
}
return new static($target);
}
/**
* Returns the file as data uri
*
* @param bool $base64 Whether the data should be base64 encoded or not
* @return string
*/
public function dataUri(bool $base64 = true): string
{
if ($base64 === true) {
return 'data:' . $this->mime() . ';base64,' . $this->base64();
}
return 'data:' . $this->mime() . ',' . Escape::url($this->read());
}
/**
* Deletes the file
*
* @return bool
*/
public function delete(): bool
{
if (F::remove($this->root) !== true) {
throw new Exception('The file "' . $this->root . '" could not be deleted');
}
return true;
}
/**
* Checks if the file actually exists
*
* @return bool
*/
public function exists(): bool
{
return file_exists($this->root) === true;
}
/**
* Returns the current lowercase extension (without .)
*
* @return string
*/
public function extension(): string
{
return F::extension($this->root);
}
/**
* Returns the filename
*
* @return string
*/
public function filename(): string
{
return basename($this->root);
}
/**
* Returns a md5 hash of the root
*
* @return string
*/
public function hash(): string
{
return md5($this->root);
}
/**
* Checks if a file is of a certain type
*
* @param string $value An extension or mime type
* @return bool
*/
public function is(string $value): bool
{
return F::is($this->root, $value);
}
/**
* Checks if the file is readable
*
* @return bool
*/
public function isReadable(): bool
{
return is_readable($this->root) === true;
}
/**
* Checks if the file is writable
*
* @return bool
*/
public function isWritable(): bool
{
return F::isWritable($this->root);
}
/**
* Detects the mime type of the file
*
* @return string|null
*/
public function mime()
{
return Mime::type($this->root);
}
/**
* Get the file's last modification time.
*
* @param string $format
* @param string $handler date or strftime
* @return mixed
*/
public function modified(string $format = null, string $handler = 'date')
{
return F::modified($this->root, $format, $handler);
}
/**
* Move the file to a new location
*
* @param string $newRoot
* @param bool $overwrite Force overwriting any existing files
* @return static
*/
public function move(string $newRoot, bool $overwrite = false)
{
if (F::move($this->root, $newRoot, $overwrite) !== true) {
throw new Exception('The file: "' . $this->root . '" could not be moved to: "' . $newRoot . '"');
}
return new static($newRoot);
}
/**
* Getter for the name of the file
* without the extension
*
* @return string
*/
public function name(): string
{
return pathinfo($this->root, PATHINFO_FILENAME);
}
/**
* Returns the file size in a
* human-readable format
*
* @return string
*/
public function niceSize(): string
{
return F::niceSize($this->root);
}
/**
* Reads the file content and returns it.
*
* @return string|false
*/
public function read()
{
return F::read($this->root);
}
/**
* Returns the absolute path to the file
*
* @return string
*/
public function realpath(): string
{
return realpath($this->root);
}
/**
* Changes the name of the file without
* touching the extension
*
* @param string $newName
* @param bool $overwrite Force overwrite existing files
* @return static
*/
public function rename(string $newName, bool $overwrite = false)
{
$newRoot = F::rename($this->root, $newName, $overwrite);
if ($newRoot === false) {
throw new Exception('The file: "' . $this->root . '" could not be renamed to: "' . $newName . '"');
}
return new static($newRoot);
}
/**
* Returns the given file path
*
* @return string|null
*/
public function root(): ?string
{
return $this->root;
}
/**
* Returns the raw size of the file
*
* @return int
*/
public function size(): int
{
return F::size($this->root);
}
/**
* Converts the media object to a
* plain PHP array
*
* @return array
*/
public function toArray(): array
{
return [
'root' => $this->root(),
'hash' => $this->hash(),
'filename' => $this->filename(),
'name' => $this->name(),
'safeName' => F::safeName($this->name()),
'extension' => $this->extension(),
'size' => $this->size(),
'niceSize' => $this->niceSize(),
'modified' => $this->modified('c'),
'mime' => $this->mime(),
'type' => $this->type(),
'isWritable' => $this->isWritable(),
'isReadable' => $this->isReadable(),
];
}
/**
* Returns the file type.
*
* @return string|false
*/
public function type()
{
return F::type($this->root);
}
/**
* Validates the file contents depending on the file type
*
* @param string|bool $typeLazy Explicit sane handler type string,
* `true` for lazy autodetection or
* `false` for normal autodetection
* @return void
*
* @throws \Kirby\Exception\InvalidArgumentException If the file didn't pass validation
* @throws \Kirby\Exception\NotFoundException If the handler was not found
* @throws \Kirby\Exception\Exception On other errors
*/
public function validateContents($typeLazy = false): void
{
Sane::validateFile($this->root(), $typeLazy);
}
/**
* Writes content to the file
*
* @param string $content
* @return bool
*/
public function write($content): bool
{
if (F::write($this->root, $content) !== true) {
throw new Exception('The file "' . $this->root . '" could not be written');
}
return true;
}
}

573
kirby/src/Toolkit/Html.php Normal file
View file

@ -0,0 +1,573 @@
<?php
namespace Kirby\Toolkit;
use Exception;
use Kirby\Http\Url;
/**
* HTML builder for the most common elements
*
* @package Kirby Toolkit
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier GmbH
* @license https://opensource.org/licenses/MIT
*/
class Html extends Xml
{
/**
* An internal store for an HTML entities translation table
*
* @var array
*/
public static $entities;
/**
* Closing string for void tags;
* can be used to switch to trailing slashes if required
*
* ```php
* Html::$void = ' />'
* ```
*
* @var string
*/
public static $void = '>';
/**
* List of HTML tags that are considered to be self-closing
*
* @var array
*/
public static $voidList = [
'area',
'base',
'br',
'col',
'command',
'embed',
'hr',
'img',
'input',
'keygen',
'link',
'meta',
'param',
'source',
'track',
'wbr'
];
/**
* Generic HTML tag generator
* Can be called like `Html::p('A paragraph', ['class' => 'text'])`
*
* @param string $tag Tag name
* @param array $arguments Further arguments for the Html::tag() method
* @return string
*/
public static function __callStatic(string $tag, array $arguments = []): string
{
if (static::isVoid($tag) === true) {
return static::tag($tag, null, ...$arguments);
}
return static::tag($tag, ...$arguments);
}
/**
* Generates an `<a>` tag; automatically supports mailto: and tel: links
*
* @param string $href The URL for the `<a>` tag
* @param string|array|null $text The optional text; if `null`, the URL will be used as text
* @param array $attr Additional attributes for the tag
* @return string The generated HTML
*/
public static function a(string $href, $text = null, array $attr = []): string
{
if (Str::startsWith($href, 'mailto:')) {
return static::email(substr($href, 7), $text, $attr);
}
if (Str::startsWith($href, 'tel:')) {
return static::tel(substr($href, 4), $text, $attr);
}
return static::link($href, $text, $attr);
}
/**
* Generates a single attribute or a list of attributes
*
* @param string|array $name String: A single attribute with that name will be generated.
* Key-value array: A list of attributes will be generated. Don't pass a second argument in that case.
* @param mixed $value If used with a `$name` string, pass the value of the attribute here.
* If used with a `$name` array, this can be set to `false` to disable attribute sorting.
* @return string|null The generated HTML attributes string
*/
public static function attr($name, $value = null): ?string
{
// HTML supports boolean attributes without values
if (is_array($name) === false && is_bool($value) === true) {
return $value === true ? strtolower($name) : null;
}
// all other cases can share the XML variant
$attr = parent::attr($name, $value);
// HTML supports named entities
$entities = parent::entities();
$html = array_keys($entities);
$xml = array_values($entities);
return str_replace($xml, $html, $attr);
}
/**
* Converts lines in a string into HTML breaks
*
* @param string $string
* @return string
*/
public static function breaks(string $string): string
{
return nl2br($string);
}
/**
* Generates an `<a>` tag with `mailto:`
*
* @param string $email The email address
* @param string|array|null $text The optional text; if `null`, the email address will be used as text
* @param array $attr Additional attributes for the tag
* @return string The generated HTML
*/
public static function email(string $email, $text = null, array $attr = []): string
{
if (empty($email) === true) {
return '';
}
if (empty($text) === true) {
// show only the email address without additional parameters
$address = Str::contains($email, '?') ? Str::before($email, '?') : $email;
$text = [Str::encode($address)];
}
$email = Str::encode($email);
$attr = array_merge([
'href' => [
'value' => 'mailto:' . $email,
'escape' => false
]
], $attr);
// add rel=noopener to target blank links to improve security
$attr['rel'] = static::rel($attr['rel'] ?? null, $attr['target'] ?? null);
return static::tag('a', $text, $attr);
}
/**
* Converts a string to an HTML-safe string
*
* @param string|null $string
* @param bool $keepTags If true, existing tags won't be escaped
* @return string The HTML string
*
* @psalm-suppress ParamNameMismatch
*/
public static function encode(?string $string, bool $keepTags = false): string
{
if ($string === null) {
return '';
}
if ($keepTags === true) {
$list = static::entities();
unset($list['"'], $list['<'], $list['>'], $list['&']);
$search = array_keys($list);
$values = array_values($list);
return str_replace($search, $values, $string);
}
return htmlentities($string, ENT_COMPAT, 'utf-8');
}
/**
* Returns the entity translation table
*
* @return array
*/
public static function entities(): array
{
return self::$entities = self::$entities ?? get_html_translation_table(HTML_ENTITIES);
}
/**
* Creates a `<figure>` tag with optional caption
*
* @param string|array $content Contents of the `<figure>` tag
* @param string|array $caption Optional `<figcaption>` text to use
* @param array $attr Additional attributes for the `<figure>` tag
* @return string The generated HTML
*/
public static function figure($content, $caption = '', array $attr = []): string
{
if ($caption) {
$figcaption = static::tag('figcaption', $caption);
if (is_string($content) === true) {
$content = [static::encode($content, false)];
}
$content[] = $figcaption;
}
return static::tag('figure', $content, $attr);
}
/**
* Embeds a GitHub Gist
*
* @param string $url Gist URL
* @param string|null $file Optional specific file to embed
* @param array $attr Additional attributes for the `<script>` tag
* @return string The generated HTML
*/
public static function gist(string $url, ?string $file = null, array $attr = []): string
{
if ($file === null) {
$src = $url . '.js';
} else {
$src = $url . '.js?file=' . $file;
}
return static::tag('script', '', array_merge($attr, ['src' => $src]));
}
/**
* Creates an `<iframe>`
*
* @param string $src
* @param array $attr Additional attributes for the `<iframe>` tag
* @return string The generated HTML
*/
public static function iframe(string $src, array $attr = []): string
{
return static::tag('iframe', '', array_merge(['src' => $src], $attr));
}
/**
* Generates an `<img>` tag
*
* @param string $src The URL of the image
* @param array $attr Additional attributes for the `<img>` tag
* @return string The generated HTML
*/
public static function img(string $src, array $attr = []): string
{
$attr = array_merge([
'src' => $src,
'alt' => ' '
], $attr);
return static::tag('img', '', $attr);
}
/**
* Checks if a tag is self-closing
*
* @param string $tag
* @return bool
*/
public static function isVoid(string $tag): bool
{
return in_array(strtolower($tag), static::$voidList);
}
/**
* Generates an `<a>` link tag (without automatic email: and tel: detection)
*
* @param string $href The URL for the `<a>` tag
* @param string|array|null $text The optional text; if `null`, the URL will be used as text
* @param array $attr Additional attributes for the tag
* @return string The generated HTML
*/
public static function link(string $href, $text = null, array $attr = []): string
{
$attr = array_merge(['href' => $href], $attr);
if (empty($text) === true) {
$text = $attr['href'];
}
if (is_string($text) === true && Str::isUrl($text) === true) {
$text = Url::short($text);
}
// add rel=noopener to target blank links to improve security
$attr['rel'] = static::rel($attr['rel'] ?? null, $attr['target'] ?? null);
return static::tag('a', $text, $attr);
}
/**
* Add noopener & noreferrer to rels when target is `_blank`
*
* @param string|null $rel Current `rel` value
* @param string|null $target Current `target` value
* @return string|null New `rel` value or `null` if not needed
*/
public static function rel(?string $rel = null, ?string $target = null): ?string
{
$rel = trim($rel);
if ($target === '_blank') {
if (empty($rel) === false) {
return $rel;
}
return trim($rel . ' noopener noreferrer', ' ');
}
return $rel;
}
/**
* Builds an HTML tag
*
* @param string $name Tag name
* @param array|string $content Scalar value or array with multiple lines of content; self-closing
* tags are generated automatically based on the `Html::isVoid()` list
* @param array $attr An associative array with additional attributes for the tag
* @param string|null $indent Indentation string, defaults to two spaces or `null` for output on one line
* @param int $level Indentation level
* @return string The generated HTML
*/
public static function tag(string $name, $content = '', array $attr = null, string $indent = null, int $level = 0): string
{
// treat an explicit `null` value as an empty tag
// as void tags are already covered below
if ($content === null) {
$content = '';
}
// force void elements to be self-closing
if (static::isVoid($name) === true) {
$content = null;
}
return parent::tag($name, $content, $attr, $indent, $level);
}
/**
* Generates an `<a>` tag for a phone number
*
* @param string $tel The phone number
* @param string|array|null $text The optional text; if `null`, the phone number will be used as text
* @param array $attr Additional attributes for the tag
* @return string The generated HTML
*/
public static function tel(string $tel, $text = null, array $attr = []): string
{
$number = preg_replace('![^0-9\+]+!', '', $tel);
if (empty($text) === true) {
$text = $tel;
}
return static::link('tel:' . $number, $text, $attr);
}
/**
* Properly encodes tag contents
*
* @param mixed $value
* @return string|null
*/
public static function value($value): ?string
{
if ($value === true) {
return 'true';
}
if ($value === false) {
return 'false';
}
if (is_numeric($value) === true) {
return (string)$value;
}
if ($value === null || $value === '') {
return null;
}
return static::encode($value, false);
}
/**
* Creates a video embed via `<iframe>` for YouTube or Vimeo
* videos; the embed URLs are automatically detected from
* the given URL
*
* @param string $url Video URL
* @param array $options Additional `vimeo` and `youtube` options
* (will be used as query params in the embed URL)
* @param array $attr Additional attributes for the `<iframe>` tag
* @return string The generated HTML
*/
public static function video(string $url, array $options = [], array $attr = []): string
{
// YouTube video
if (preg_match('!youtu!i', $url) === 1) {
return static::youtube($url, $options['youtube'] ?? [], $attr);
}
// Vimeo video
if (preg_match('!vimeo!i', $url) === 1) {
return static::vimeo($url, $options['vimeo'] ?? [], $attr);
}
throw new Exception('Unexpected video type');
}
/**
* Embeds a Vimeo video by URL in an `<iframe>`
*
* @param string $url Vimeo video URL
* @param array $options Query params for the embed URL
* @param array $attr Additional attributes for the `<iframe>` tag
* @return string The generated HTML
*/
public static function vimeo(string $url, array $options = [], array $attr = []): string
{
if (preg_match('!vimeo.com\/([0-9]+)!i', $url, $array) === 1) {
$id = $array[1];
} elseif (preg_match('!player.vimeo.com\/video\/([0-9]+)!i', $url, $array) === 1) {
$id = $array[1];
} else {
throw new Exception('Invalid Vimeo source');
}
// build the options query
if (empty($options) === false) {
$query = '?' . http_build_query($options);
} else {
$query = '';
}
$url = 'https://player.vimeo.com/video/' . $id . $query;
return static::iframe($url, array_merge(['allowfullscreen' => true], $attr));
}
/**
* Embeds a YouTube video by URL in an `<iframe>`
*
* @param string $url YouTube video URL
* @param array $options Query params for the embed URL
* @param array $attr Additional attributes for the `<iframe>` tag
* @return string The generated HTML
*/
public static function youtube(string $url, array $options = [], array $attr = []): string
{
// default YouTube embed domain
$domain = 'youtube.com';
$uri = 'embed/';
$id = null;
$urlOptions = [];
$schemes = [
// https://www.youtube.com/embed/videoseries?list=PLj8e95eaxiB9goOAvINIy4Vt3mlWQJxys
[
'pattern' => 'youtube.com\/embed\/videoseries\?list=([a-zA-Z0-9_-]+)',
'uri' => 'embed/videoseries?list='
],
// https://www.youtube-nocookie.com/embed/videoseries?list=PLj8e95eaxiB9goOAvINIy4Vt3mlWQJxys
[
'pattern' => 'youtube-nocookie.com\/embed\/videoseries\?list=([a-zA-Z0-9_-]+)',
'domain' => 'www.youtube-nocookie.com',
'uri' => 'embed/videoseries?list='
],
// https://www.youtube.com/embed/d9NF2edxy-M
// https://www.youtube.com/embed/d9NF2edxy-M?start=10
['pattern' => 'youtube.com\/embed\/([a-zA-Z0-9_-]+)(?:\?(.+))?'],
// https://www.youtube-nocookie.com/embed/d9NF2edxy-M
// https://www.youtube-nocookie.com/embed/d9NF2edxy-M?start=10
[
'pattern' => 'youtube-nocookie.com\/embed\/([a-zA-Z0-9_-]+)(?:\?(.+))?',
'domain' => 'www.youtube-nocookie.com'
],
// https://www.youtube-nocookie.com/watch?v=d9NF2edxy-M
// https://www.youtube-nocookie.com/watch?v=d9NF2edxy-M&t=10
[
'pattern' => 'youtube-nocookie.com\/watch\?v=([a-zA-Z0-9_-]+)(?:&(.+))?',
'domain' => 'www.youtube-nocookie.com'
],
// https://www.youtube-nocookie.com/playlist?list=PLj8e95eaxiB9goOAvINIy4Vt3mlWQJxys
[
'pattern' => 'youtube-nocookie.com\/playlist\?list=([a-zA-Z0-9_-]+)',
'domain' => 'www.youtube-nocookie.com',
'uri' => 'embed/videoseries?list='
],
// https://www.youtube.com/watch?v=d9NF2edxy-M
// https://www.youtube.com/watch?v=d9NF2edxy-M&t=10
['pattern' => 'youtube.com\/watch\?v=([a-zA-Z0-9_-]+)(?:&(.+))?'],
// https://www.youtube.com/playlist?list=PLj8e95eaxiB9goOAvINIy4Vt3mlWQJxys
[
'pattern' => 'youtube.com\/playlist\?list=([a-zA-Z0-9_-]+)',
'uri' => 'embed/videoseries?list='
],
// https://youtu.be/d9NF2edxy-M
// https://youtu.be/d9NF2edxy-M?t=10
['pattern' => 'youtu.be\/([a-zA-Z0-9_-]+)(?:\?(.+))?']
];
foreach ($schemes as $schema) {
if (preg_match('!' . $schema['pattern'] . '!i', $url, $array) === 1) {
$domain = $schema['domain'] ?? $domain;
$uri = $schema['uri'] ?? $uri;
$id = $array[1];
if (isset($array[2]) === true) {
parse_str($array[2], $urlOptions);
// convert video URL options to embed URL options
if (isset($urlOptions['t']) === true) {
$urlOptions['start'] = $urlOptions['t'];
unset($urlOptions['t']);
}
}
break;
}
}
// no match
if ($id === null) {
throw new Exception('Invalid YouTube source');
}
// build the options query
if (empty($options) === false || empty($urlOptions) === false) {
$query = (Str::contains($uri, '?') === true ? '&' : '?') . http_build_query(array_merge($urlOptions, $options));
} else {
$query = '';
}
$url = 'https://' . $domain . '/' . $uri . $id . $query;
return static::iframe($url, array_merge(['allowfullscreen' => true], $attr));
}
}

308
kirby/src/Toolkit/I18n.php Normal file
View file

@ -0,0 +1,308 @@
<?php
namespace Kirby\Toolkit;
use Closure;
use NumberFormatter;
/**
* Localization class, roughly inspired by VueI18n
*
* @package Kirby Toolkit
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier GmbH
* @license https://opensource.org/licenses/MIT
*/
class I18n
{
/**
* Custom loader function
*
* @var Closure
*/
public static $load = null;
/**
* Current locale
*
* @var string
*/
public static $locale = 'en';
/**
* All registered translations
*
* @var array
*/
public static $translations = [];
/**
* The fallback locale or a
* list of fallback locales
*
* @var string|array
*/
public static $fallback = ['en'];
/**
* Cache of `NumberFormatter` objects by locale
*
* @var array
*/
protected static $decimalsFormatters = [];
/**
* Returns the first fallback locale
*
* @deprecated 3.5.1 Use `\Kirby\Toolkit\I18n::fallbacks()` instead
* @todo Add deprecated() helper warning in 3.6.0
* @todo Remove in 3.7.0
*
* @return string
*/
public static function fallback(): string
{
return static::fallbacks()[0];
}
/**
* Returns the list of fallback locales
*
* @return array
*/
public static function fallbacks(): array
{
if (
is_array(static::$fallback) === true ||
is_string(static::$fallback) === true
) {
return A::wrap(static::$fallback);
}
if (is_callable(static::$fallback) === true) {
return static::$fallback = A::wrap((static::$fallback)());
}
return static::$fallback = ['en'];
}
/**
* Returns singular or plural
* depending on the given number
*
* @param int $count
* @param bool $none If true, 'none' will be returned if the count is 0
* @return string
*/
public static function form(int $count, bool $none = false): string
{
if ($none === true && $count === 0) {
return 'none';
}
return $count === 1 ? 'singular' : 'plural';
}
/**
* Formats a number
*
* @param int|float $number
* @param string $locale
* @return string
*/
public static function formatNumber($number, string $locale = null): string
{
$locale = $locale ?? static::locale();
$formatter = static::decimalNumberFormatter($locale);
if ($formatter !== null) {
$number = $formatter->format($number);
}
return (string)$number;
}
/**
* Returns the locale code
*
* @return string
*/
public static function locale(): string
{
if (is_string(static::$locale) === true) {
return static::$locale;
}
if (is_callable(static::$locale) === true) {
return static::$locale = (static::$locale)();
}
return static::$locale = 'en';
}
/**
* Translates a given message
* according to the currently set locale
*
* @param string|array $key
* @param string|array|null $fallback
* @param string|null $locale
* @return string|array|null
*/
public static function translate($key, $fallback = null, string $locale = null)
{
$locale = $locale ?? static::locale();
if (is_array($key) === true) {
if (isset($key[$locale])) {
return $key[$locale];
}
if (is_array($fallback)) {
return $fallback[$locale] ?? $fallback['en'] ?? reset($fallback);
}
return $fallback;
}
if ($translation = static::translation($locale)[$key] ?? null) {
return $translation;
}
if ($fallback !== null) {
return $fallback;
}
foreach (static::fallbacks() as $fallback) {
// skip locales we have already tried
if ($locale === $fallback) {
continue;
}
if ($translation = static::translation($fallback)[$key] ?? null) {
return $translation;
}
}
return null;
}
/**
* Translate by key and then replace
* placeholders in the text
*
* @param string $key
* @param string|array|null $fallback
* @param array|null $replace
* @param string|null $locale
* @return string
*/
public static function template(string $key, $fallback = null, ?array $replace = null, ?string $locale = null): string
{
if (is_array($fallback) === true) {
$replace = $fallback;
$fallback = null;
$locale = null;
}
$template = static::translate($key, $fallback, $locale);
return Str::template($template, $replace, '-', '{', '}');
}
/**
* Returns the current or any other translation
* by locale. If the translation does not exist
* yet, the loader will try to load it, if defined.
*
* @param string|null $locale
* @return array
*/
public static function translation(string $locale = null): array
{
$locale = $locale ?? static::locale();
if (isset(static::$translations[$locale]) === true) {
return static::$translations[$locale];
}
if (is_a(static::$load, 'Closure') === true) {
return static::$translations[$locale] = (static::$load)($locale);
}
return static::$translations[$locale] = [];
}
/**
* Returns all loaded or defined translations
*
* @return array
*/
public static function translations(): array
{
return static::$translations;
}
/**
* Returns (and creates) a decimal number formatter for a given locale
*
* @return \NumberFormatter|null
*/
protected static function decimalNumberFormatter(string $locale): ?NumberFormatter
{
if (isset(static::$decimalsFormatters[$locale])) {
return static::$decimalsFormatters[$locale];
}
if (extension_loaded('intl') !== true || class_exists('NumberFormatter') !== true) {
return null; // @codeCoverageIgnore
}
return static::$decimalsFormatters[$locale] = new NumberFormatter($locale, NumberFormatter::DECIMAL);
}
/**
* Translates amounts
*
* Translation definition options:
* - Translation is a simple string: `{{ count }}` gets replaced in the template
* - Translation is an array with a value for each count: Chooses the correct template and
* replaces `{{ count }}` in the template; if no specific template for the input count is
* defined, the template that is defined last in the translation array is used
* - Translation is a callback with a `$count` argument: Returns the callback return value
*
* @param string $key
* @param int $count
* @param string $locale
* @param bool $formatNumber If set to `false`, the count is not formatted
* @return mixed
*/
public static function translateCount(string $key, int $count, string $locale = null, bool $formatNumber = true)
{
$locale = $locale ?? static::locale();
$translation = static::translate($key, null, $locale);
if ($translation === null) {
return null;
}
if (is_a($translation, 'Closure') === true) {
return $translation($count);
}
if (is_string($translation) === true) {
$message = $translation;
} else {
if (isset($translation[$count]) === true) {
$message = $translation[$count];
} else {
$message = end($translation);
}
}
if ($formatNumber === true) {
$count = static::formatNumber($count, $locale);
}
return str_replace('{{ count }}', $count, $message);
}
}

View file

@ -0,0 +1,181 @@
<?php
namespace Kirby\Toolkit;
use ArrayIterator;
use IteratorAggregate;
/**
* Extended version of PHP's iterator
* class that builds the foundation of our
* Collection class.
*
* @package Kirby Toolkit
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier GmbH
* @license https://opensource.org/licenses/MIT
*/
class Iterator implements IteratorAggregate
{
/**
* The data array
*
* @var array
*/
public $data = [];
/**
* Constructor
*
* @param array $data
*/
public function __construct(array $data = [])
{
$this->data = $data;
}
/**
* Get an iterator for the items.
*
* @return \ArrayIterator
*/
public function getIterator()
{
return new ArrayIterator($this->data);
}
/**
* Returns the current key
*
* @return string
*/
public function key()
{
return key($this->data);
}
/**
* Returns an array of all keys
*
* @return array
*/
public function keys(): array
{
return array_keys($this->data);
}
/**
* Returns the current element
*
* @return mixed
*/
public function current()
{
return current($this->data);
}
/**
* Moves the cursor to the previous element
* and returns it
*
* @return mixed
*/
public function prev()
{
return prev($this->data);
}
/**
* Moves the cursor to the next element
* and returns it
*
* @return mixed
*/
public function next()
{
return next($this->data);
}
/**
* Moves the cusor to the first element
*/
public function rewind()
{
reset($this->data);
}
/**
* Checks if the current element is valid
*
* @return bool
*/
public function valid(): bool
{
return $this->current() !== false;
}
/**
* Counts all elements
*
* @return int
*/
public function count(): int
{
return count($this->data);
}
/**
* Tries to find the index number for the given element
*
* @param mixed $needle the element to search for
* @return int|false the index (int) of the element or false
*/
public function indexOf($needle)
{
return array_search($needle, array_values($this->data));
}
/**
* Tries to find the key for the given element
*
* @param mixed $needle the element to search for
* @return string|false the name of the key or false
*/
public function keyOf($needle)
{
return array_search($needle, $this->data);
}
/**
* Checks by key if an element is included
*
* @param mixed $key
* @return bool
*/
public function has($key): bool
{
return isset($this->data[$key]);
}
/**
* Checks if the current key is set
*
* @param mixed $key the key to check
* @return bool
*/
public function __isset($key): bool
{
return $this->has($key);
}
/**
* Simplified var_dump output
*
* @return array
*/
public function __debugInfo(): array
{
return $this->data;
}
}

View file

@ -0,0 +1,183 @@
<?php
namespace Kirby\Toolkit;
use Kirby\Exception\Exception;
use Kirby\Exception\InvalidArgumentException;
/**
* PHP locale handling
* @since 3.5.0
*
* @package Kirby Toolkit
* @author Lukas Bestle <lukas@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier GmbH
* @license https://opensource.org/licenses/MIT
*/
class Locale
{
/**
* List of all locale constants supported by PHP
*/
const LOCALE_CONSTANTS = [
'LC_COLLATE', 'LC_CTYPE', 'LC_MONETARY',
'LC_NUMERIC', 'LC_TIME', 'LC_MESSAGES'
];
/**
* Converts a normalized locale array to an array with the
* locale constants replaced with their string representations
*
* @param array $locale
* @return array
*/
public static function export(array $locale): array
{
$constants = static::supportedConstants(true);
// replace the keys in the locale data array with the locale names
$return = [];
foreach ($locale as $key => $value) {
if (isset($constants[$key]) === true) {
// the key is a valid constant,
// replace it with its string representation
$return[$constants[$key]] = $value;
} else {
// not found, keep it as-is
$return[$key] = $value;
}
}
return $return;
}
/**
* Returns the current locale value for
* a specified or for all locale categories
* @since 3.5.6
*
* @param int|string $category Locale category constant or constant name
* @return array|string Associative array if `LC_ALL` was passed (default), otherwise string
*
* @throws \Kirby\Exception\Exception If the locale cannot be determined
* @throws \Kirby\Exception\InvalidArgumentException If the provided locale category is invalid
*/
public static function get($category = LC_ALL)
{
$normalizedCategory = static::normalizeConstant($category);
if (is_int($normalizedCategory) !== true) {
throw new InvalidArgumentException('Invalid locale category "' . $category . '"');
}
if ($normalizedCategory !== LC_ALL) {
// `setlocale(..., 0)` actually *gets* the locale
$locale = setlocale($normalizedCategory, 0);
if (is_string($locale) !== true) {
throw new Exception('Could not determine locale for category "' . $category . '"');
}
return $locale;
}
// no specific `$category` was passed, make a list of all locales
$array = [];
foreach (static::supportedConstants() as $constant => $name) {
// `setlocale(..., 0)` actually *gets* the locale
$array[$constant] = setlocale($constant, '0');
}
// if all values are the same, we can use `LC_ALL`
// instead of a long array with all constants
if (count(array_unique($array)) === 1) {
return [
LC_ALL => array_shift($array)
];
}
return $array;
}
/**
* Converts a locale string or an array with constant or
* string keys to a normalized constant => value array
*
* @param array|string $locale
* @return array
*/
public static function normalize($locale): array
{
if (is_array($locale)) {
// replace string constant keys with the constant values
$convertedLocale = [];
foreach ($locale as $key => $value) {
$convertedLocale[static::normalizeConstant($key)] = $value;
}
return $convertedLocale;
} elseif (is_string($locale)) {
return [LC_ALL => $locale];
} else {
throw new InvalidArgumentException('Locale must be string or array');
}
}
/**
* Sets the PHP locale with a locale string or
* an array with constant or string keys
*
* @param array|string $locale
* @return void
*/
public static function set($locale): void
{
$locale = static::normalize($locale);
foreach ($locale as $key => $value) {
setlocale($key, $value);
}
}
/**
* Tries to convert an `LC_*` constant name
* to its constant value
*
* @param int|string $constant
* @return int|string
*/
protected static function normalizeConstant($constant)
{
if (is_string($constant) === true && Str::startsWith($constant, 'LC_') === true) {
return constant($constant);
}
// already an int or we cannot convert it safely
return $constant;
}
/**
* Builds an associative array with the locales
* that are actually supported on this system
*
* @param bool $withAll If set to `true`, `LC_ALL` is returned as well
* @return array
*/
protected static function supportedConstants(bool $withAll = false): array
{
$names = static::LOCALE_CONSTANTS;
if ($withAll === true) {
array_unshift($names, 'LC_ALL');
}
$constants = [];
foreach ($names as $name) {
if (defined($name) === true) {
$constants[constant($name)] = $name;
}
}
return $constants;
}
}

343
kirby/src/Toolkit/Mime.php Normal file
View file

@ -0,0 +1,343 @@
<?php
namespace Kirby\Toolkit;
use SimpleXMLElement;
/**
* The `Mime` class provides method
* for MIME type detection or guessing
* from different criteria like
* extensions etc.
*
* @package Kirby Toolkit
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier GmbH
* @license https://opensource.org/licenses/MIT
*/
class Mime
{
/**
* Extension to MIME type map
*
* @var array
*/
public static $types = [
'ai' => 'application/postscript',
'aif' => 'audio/x-aiff',
'aifc' => 'audio/x-aiff',
'aiff' => 'audio/x-aiff',
'avi' => 'video/x-msvideo',
'avif' => 'image/avif',
'bmp' => 'image/bmp',
'css' => 'text/css',
'csv' => ['text/csv', 'text/x-comma-separated-values', 'text/comma-separated-values', 'application/octet-stream'],
'doc' => 'application/msword',
'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'dotx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.template',
'dvi' => 'application/x-dvi',
'eml' => 'message/rfc822',
'eps' => 'application/postscript',
'exe' => ['application/octet-stream', 'application/x-msdownload'],
'gif' => 'image/gif',
'gtar' => 'application/x-gtar',
'gz' => 'application/x-gzip',
'htm' => 'text/html',
'html' => 'text/html',
'ico' => 'image/x-icon',
'ics' => 'text/calendar',
'js' => 'application/x-javascript',
'json' => ['application/json', 'text/json'],
'j2k' => ['image/jp2'],
'jp2' => ['image/jp2'],
'jpg' => ['image/jpeg', 'image/pjpeg'],
'jpeg' => ['image/jpeg', 'image/pjpeg'],
'jpe' => ['image/jpeg', 'image/pjpeg'],
'log' => ['text/plain', 'text/x-log'],
'm4a' => 'audio/mp4',
'm4v' => 'video/mp4',
'mid' => 'audio/midi',
'midi' => 'audio/midi',
'mif' => 'application/vnd.mif',
'mov' => 'video/quicktime',
'movie' => 'video/x-sgi-movie',
'mp2' => 'audio/mpeg',
'mp3' => ['audio/mpeg', 'audio/mpg', 'audio/mpeg3', 'audio/mp3'],
'mp4' => 'video/mp4',
'mpe' => 'video/mpeg',
'mpeg' => 'video/mpeg',
'mpg' => 'video/mpeg',
'mpga' => 'audio/mpeg',
'odc' => 'application/vnd.oasis.opendocument.chart',
'odp' => 'application/vnd.oasis.opendocument.presentation',
'odt' => 'application/vnd.oasis.opendocument.text',
'pdf' => ['application/pdf', 'application/x-download'],
'php' => ['text/php', 'text/x-php', 'application/x-httpd-php', 'application/php', 'application/x-php', 'application/x-httpd-php-source'],
'php3' => ['text/php', 'text/x-php', 'application/x-httpd-php', 'application/php', 'application/x-php', 'application/x-httpd-php-source'],
'phps' => ['text/php', 'text/x-php', 'application/x-httpd-php', 'application/php', 'application/x-php', 'application/x-httpd-php-source'],
'phtml' => ['text/php', 'text/x-php', 'application/x-httpd-php', 'application/php', 'application/x-php', 'application/x-httpd-php-source'],
'png' => 'image/png',
'ppt' => ['application/powerpoint', 'application/vnd.ms-powerpoint'],
'pptx' => 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'potx' => 'application/vnd.openxmlformats-officedocument.presentationml.template',
'ps' => 'application/postscript',
'psd' => 'application/x-photoshop',
'qt' => 'video/quicktime',
'rss' => 'application/rss+xml',
'rtf' => 'text/rtf',
'rtx' => 'text/richtext',
'shtml' => 'text/html',
'svg' => 'image/svg+xml',
'swf' => 'application/x-shockwave-flash',
'tar' => 'application/x-tar',
'text' => 'text/plain',
'txt' => 'text/plain',
'tgz' => ['application/x-tar', 'application/x-gzip-compressed'],
'tif' => 'image/tiff',
'tiff' => 'image/tiff',
'wav' => 'audio/x-wav',
'wbxml' => 'application/wbxml',
'webm' => 'video/webm',
'webp' => 'image/webp',
'word' => ['application/msword', 'application/octet-stream'],
'xhtml' => 'application/xhtml+xml',
'xht' => 'application/xhtml+xml',
'xml' => 'text/xml',
'xl' => 'application/excel',
'xls' => ['application/excel', 'application/vnd.ms-excel', 'application/msexcel'],
'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'xltx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.template',
'xsl' => 'text/xml',
'yaml' => ['application/yaml', 'text/yaml'],
'yml' => ['application/yaml', 'text/yaml'],
'zip' => ['application/x-zip', 'application/zip', 'application/x-zip-compressed'],
];
/**
* Fixes an invalid MIME type guess for the given file
*
* @param string $file
* @param string $mime
* @param string $extension
* @return string|null
*/
public static function fix(string $file, string $mime = null, string $extension = null)
{
// fixing map
$map = [
'text/html' => [
'svg' => ['Kirby\Toolkit\Mime', 'fromSvg'],
],
'text/plain' => [
'css' => 'text/css',
'json' => 'application/json',
'svg' => ['Kirby\Toolkit\Mime', 'fromSvg'],
],
'text/x-asm' => [
'css' => 'text/css'
],
'image/svg' => [
'svg' => 'image/svg+xml'
]
];
if ($mode = ($map[$mime][$extension] ?? null)) {
if (is_callable($mode) === true) {
return $mode($file, $mime, $extension);
}
if (is_string($mode) === true) {
return $mode;
}
}
return $mime;
}
/**
* Guesses a MIME type by extension
*
* @param string $extension
* @return string|null
*/
public static function fromExtension(string $extension)
{
$mime = static::$types[$extension] ?? null;
return is_array($mime) === true ? array_shift($mime) : $mime;
}
/**
* Returns the MIME type of a file
*
* @param string $file
* @return string|false
*/
public static function fromFileInfo(string $file)
{
if (function_exists('finfo_file') === true && file_exists($file) === true) {
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime = finfo_file($finfo, $file);
finfo_close($finfo);
return $mime;
}
return false;
}
/**
* Returns the MIME type of a file
*
* @param string $file
* @return string|false
*/
public static function fromMimeContentType(string $file)
{
if (function_exists('mime_content_type') === true && file_exists($file) === true) {
return mime_content_type($file);
}
return false;
}
/**
* Tries to detect a valid SVG and returns the MIME type accordingly
*
* @param string $file
* @return string|false
*/
public static function fromSvg(string $file)
{
if (file_exists($file) === true) {
libxml_use_internal_errors(true);
$svg = new SimpleXMLElement(file_get_contents($file));
if ($svg !== false && $svg->getName() === 'svg') {
return 'image/svg+xml';
}
}
return false;
}
/**
* Tests if a given MIME type is matched by an `Accept` header
* pattern; returns true if the MIME type is contained at all
*
* @param string $mime
* @param string $pattern
* @return bool
*/
public static function isAccepted(string $mime, string $pattern): bool
{
$accepted = Str::accepted($pattern);
foreach ($accepted as $m) {
if (static::matches($mime, $m['value']) === true) {
return true;
}
}
return false;
}
/**
* Tests if a MIME wildcard pattern from an `Accept` header
* matches a given type
* @since 3.3.0
*
* @param string $test
* @param string $wildcard
* @return bool
*/
public static function matches(string $test, string $wildcard): bool
{
return fnmatch($wildcard, $test, FNM_PATHNAME) === true;
}
/**
* Returns the extension for a given MIME type
*
* @param string|null $mime
* @return string|false
*/
public static function toExtension(string $mime = null)
{
foreach (static::$types as $key => $value) {
if (is_array($value) === true && in_array($mime, $value) === true) {
return $key;
}
if ($value === $mime) {
return $key;
}
}
return false;
}
/**
* Returns all available extensions for a given MIME type
*
* @param string|null $mime
* @return array
*/
public static function toExtensions(string $mime = null): array
{
$extensions = [];
foreach (static::$types as $key => $value) {
if (is_array($value) === true && in_array($mime, $value) === true) {
$extensions[] = $key;
continue;
}
if ($value === $mime) {
$extensions[] = $key;
continue;
}
}
return $extensions;
}
/**
* Returns the MIME type of a file
*
* @param string $file
* @param string $extension
* @return string|false
*/
public static function type(string $file, string $extension = null)
{
// use the standard finfo extension
$mime = static::fromFileInfo($file);
// use the mime_content_type function
if ($mime === false) {
$mime = static::fromMimeContentType($file);
}
// get the extension or extract it from the filename
$extension = $extension ?? F::extension($file);
// try to guess the mime type at least
if ($mime === false) {
$mime = static::fromExtension($extension);
}
// fix broken mime detection
return static::fix($file, $mime, $extension);
}
/**
* Returns all detectable MIME types
*
* @return array
*/
public static function types(): array
{
return static::$types;
}
}

106
kirby/src/Toolkit/Obj.php Normal file
View file

@ -0,0 +1,106 @@
<?php
namespace Kirby\Toolkit;
use stdClass;
/**
* Super simple stdClass extension with
* magic getter methods for all properties
*
* @package Kirby Toolkit
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier GmbH
* @license https://opensource.org/licenses/MIT
*/
class Obj extends stdClass
{
/**
* Constructor
*
* @param array $data
*/
public function __construct(array $data = [])
{
foreach ($data as $key => $val) {
$this->$key = $val;
}
}
/**
* Magic getter
*
* @param string $property
* @param array $arguments
* @return mixed
*/
public function __call(string $property, array $arguments)
{
return $this->$property ?? null;
}
/**
* Improved `var_dump` output
*
* @return array
*/
public function __debugInfo(): array
{
return $this->toArray();
}
/**
* Magic property getter
*
* @param string $property
* @return mixed
*/
public function __get(string $property)
{
return null;
}
/**
* Property Getter
*
* @param string $property
* @param mixed $fallback
* @return mixed
*/
public function get(string $property, $fallback = null)
{
return $this->$property ?? $fallback;
}
/**
* Converts the object to an array
*
* @return array
*/
public function toArray(): array
{
$result = [];
foreach ((array)$this as $key => $value) {
if (is_object($value) === true && method_exists($value, 'toArray')) {
$result[$key] = $value->toArray();
} else {
$result[$key] = $value;
}
}
return $result;
}
/**
* Converts the object to a json string
*
* @param mixed ...$arguments
* @return string
*/
public function toJson(...$arguments): string
{
return json_encode($this->toArray(), ...$arguments);
}
}

View file

@ -0,0 +1,488 @@
<?php
namespace Kirby\Toolkit;
use Kirby\Exception\ErrorPageException;
use Kirby\Exception\Exception;
/**
* Basic pagination handling
*
* @package Kirby Toolkit
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier GmbH
* @license https://opensource.org/licenses/MIT
*/
class Pagination
{
use Properties {
setProperties as protected baseSetProperties;
}
/**
* The current page
*
* @var int
*/
protected $page;
/**
* Total number of items
*
* @var int
*/
protected $total = 0;
/**
* The number of items per page
*
* @var int
*/
protected $limit = 20;
/**
* Whether validation of the pagination page
* is enabled; will throw Exceptions if true
*
* @var bool
*/
public static $validate = true;
/**
* Creates a new pagination object
* with the given parameters
*
* @param array $props
*/
public function __construct(array $props = [])
{
$this->setProperties($props);
}
/**
* Creates a pagination instance for the given
* collection with a flexible argument api
*
* @param \Kirby\Toolkit\Collection $collection
* @param mixed ...$arguments
* @return static
*/
public static function for(Collection $collection, ...$arguments)
{
$a = $arguments[0] ?? null;
$b = $arguments[1] ?? null;
$params = [];
if (is_array($a) === true) {
/**
* First argument is an option array
*
* $collection->paginate([...])
*/
$params = $a;
} elseif (is_int($a) === true && $b === null) {
/**
* First argument is the limit
*
* $collection->paginate(10)
*/
$params['limit'] = $a;
} elseif (is_int($a) === true && is_int($b) === true) {
/**
* First argument is the limit,
* second argument is the page
*
* $collection->paginate(10, 2)
*/
$params['limit'] = $a;
$params['page'] = $b;
} elseif (is_int($a) === true && is_array($b) === true) {
/**
* First argument is the limit,
* second argument are options
*
* $collection->paginate(10, [...])
*/
$params = $b;
$params['limit'] = $a;
}
// add the total count from the collection
$params['total'] = $collection->count();
// remove null values to make later merges work properly
$params = array_filter($params);
// create the pagination instance
return new static($params);
}
/**
* Getter for the current page
*
* @return int
*/
public function page(): int
{
return $this->page;
}
/**
* Getter for the total number of items
*
* @return int
*/
public function total(): int
{
return $this->total;
}
/**
* Getter for the number of items per page
*
* @return int
*/
public function limit(): int
{
return $this->limit;
}
/**
* Returns the index of the first item on the page
*
* @return int
*/
public function start(): int
{
$index = $this->page() - 1;
if ($index < 0) {
$index = 0;
}
return $index * $this->limit() + 1;
}
/**
* Returns the index of the last item on the page
*
* @return int
*/
public function end(): int
{
$value = ($this->start() - 1) + $this->limit();
if ($value <= $this->total()) {
return $value;
}
return $this->total();
}
/**
* Returns the total number of pages
*
* @return int
*/
public function pages(): int
{
if ($this->total() === 0) {
return 0;
}
return (int)ceil($this->total() / $this->limit());
}
/**
* Returns the first page
*
* @return int
*/
public function firstPage(): int
{
return $this->total() === 0 ? 0 : 1;
}
/**
* Returns the last page
*
* @return int
*/
public function lastPage(): int
{
return $this->pages();
}
/**
* Returns the offset (i.e. for db queries)
*
* @return int
*/
public function offset(): int
{
return $this->start() - 1;
}
/**
* Checks if the given page exists
*
* @param int $page
* @return bool
*/
public function hasPage(int $page): bool
{
if ($page <= 0) {
return false;
}
if ($page > $this->pages()) {
return false;
}
return true;
}
/**
* Checks if there are any pages at all
*
* @return bool
*/
public function hasPages(): bool
{
return $this->total() > $this->limit();
}
/**
* Checks if there's a previous page
*
* @return bool
*/
public function hasPrevPage(): bool
{
return $this->page() > 1;
}
/**
* Returns the previous page
*
* @return int|null
*/
public function prevPage()
{
return $this->hasPrevPage() ? $this->page() - 1 : null;
}
/**
* Checks if there's a next page
*
* @return bool
*/
public function hasNextPage(): bool
{
return $this->end() < $this->total();
}
/**
* Returns the next page
*
* @return int|null
*/
public function nextPage()
{
return $this->hasNextPage() ? $this->page() + 1 : null;
}
/**
* Checks if the current page is the first page
*
* @return bool
*/
public function isFirstPage(): bool
{
return $this->page() === $this->firstPage();
}
/**
* Checks if the current page is the last page
*
* @return bool
*/
public function isLastPage(): bool
{
return $this->page() === $this->lastPage();
}
/**
* Creates a range of page numbers for Google-like pagination
*
* @param int $range
* @return array
*/
public function range(int $range = 5): array
{
$page = $this->page();
$pages = $this->pages();
$start = 1;
$end = $pages;
if ($pages <= $range) {
return range($start, $end);
}
$middle = (int)floor($range/2);
$start = $page - $middle + ($range % 2 === 0);
$end = $start + $range - 1;
if ($start <= 0) {
$end = $range;
$start = 1;
}
if ($end > $pages) {
$start = $pages - $range + 1;
$end = $pages;
}
return range($start, $end);
}
/**
* Returns the first page of the created range
*
* @param int $range
* @return int
*/
public function rangeStart(int $range = 5): int
{
return $this->range($range)[0];
}
/**
* Returns the last page of the created range
*
* @param int $range
* @return int
*/
public function rangeEnd(int $range = 5): int
{
$range = $this->range($range);
return array_pop($range);
}
/**
* Sets the properties limit, total and page
* and validates that the properties match
*
* @param array $props Array with keys limit, total and/or page
* @return $this
*/
protected function setProperties(array $props)
{
$this->baseSetProperties($props);
// ensure that page is set to something, otherwise
// generate "default page" based on other params
if ($this->page === null) {
$this->page = $this->firstPage();
}
// allow a page value of 1 even if there are no pages;
// otherwise the exception will get thrown for this pretty common case
$min = $this->firstPage();
$max = $this->pages();
if ($this->page === 1 && $max === 0) {
$this->page = 0;
}
// validate page based on all params if validation is enabled,
// otherwise limit the page number to the bounds
if ($this->page < $min || $this->page > $max) {
if (static::$validate === true) {
throw new ErrorPageException('Pagination page ' . $this->page . ' does not exist, expected ' . $min . '-' . $max);
} else {
$this->page = max(min($this->page, $max), $min);
}
}
return $this;
}
/**
* Sets the number of items per page
*
* @param int $limit
* @return $this
*/
protected function setLimit(int $limit = 20)
{
if ($limit < 1) {
throw new Exception('Invalid pagination limit: ' . $limit);
}
$this->limit = $limit;
return $this;
}
/**
* Sets the total number of items
*
* @param int $total
* @return $this
*/
protected function setTotal(int $total = 0)
{
if ($total < 0) {
throw new Exception('Invalid total number of items: ' . $total);
}
$this->total = $total;
return $this;
}
/**
* Sets the current page
*
* @param int|string|null $page Int or int in string form;
* automatically determined if null
* @return $this
*/
protected function setPage($page = null)
{
// if $page is null, it is set to a default in the setProperties() method
if ($page !== null) {
if (is_numeric($page) !== true || $page < 0) {
throw new Exception('Invalid page number: ' . $page);
}
$this->page = (int)$page;
}
return $this;
}
/**
* Returns an array with all properties
*
* @return array
*/
public function toArray(): array
{
return [
'page' => $this->page(),
'firstPage' => $this->firstPage(),
'lastPage' => $this->lastPage(),
'pages' => $this->pages(),
'offset' => $this->offset(),
'limit' => $this->limit(),
'total' => $this->total(),
'start' => $this->start(),
'end' => $this->end(),
];
}
}

View file

@ -0,0 +1,151 @@
<?php
namespace Kirby\Toolkit;
use Exception;
use ReflectionMethod;
/**
* Properties
*
* @package Kirby Toolkit
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier GmbH
* @license https://opensource.org/licenses/MIT
*/
trait Properties
{
protected $propertyData = [];
/**
* Creates an instance with the same
* initial properties.
*
* @param array $props
* @return static
*/
public function clone(array $props = [])
{
return new static(array_replace_recursive($this->propertyData, $props));
}
/**
* Creates a clone and fetches all
* lazy-loaded getters to get a full copy
*
* @return static
*/
public function hardcopy()
{
$clone = $this->clone();
$clone->propertiesToArray();
return $clone;
}
protected function isRequiredProperty(string $name): bool
{
$method = new ReflectionMethod($this, 'set' . $name);
return $method->getNumberOfRequiredParameters() > 0;
}
protected function propertiesToArray()
{
$array = [];
foreach (get_object_vars($this) as $name => $default) {
if ($name === 'propertyData') {
continue;
}
if (method_exists($this, 'convert' . $name . 'ToArray') === true) {
$array[$name] = $this->{'convert' . $name . 'ToArray'}();
continue;
}
if (method_exists($this, $name) === true) {
$method = new ReflectionMethod($this, $name);
if ($method->isPublic() === true) {
$value = $this->$name();
if (is_object($value) === false) {
$array[$name] = $value;
}
}
}
}
ksort($array);
return $array;
}
protected function setOptionalProperties(array $props, array $optional)
{
$this->propertyData = array_merge($this->propertyData, $props);
foreach ($optional as $propertyName) {
if (isset($props[$propertyName]) === true) {
$this->{'set' . $propertyName}($props[$propertyName]);
} else {
$this->{'set' . $propertyName}();
}
}
}
protected function setProperties($props, array $keys = null)
{
foreach (get_object_vars($this) as $name => $default) {
if ($name === 'propertyData') {
continue;
}
$this->setProperty($name, $props[$name] ?? $default);
}
return $this;
}
protected function setProperty($name, $value, $required = null)
{
// use a setter if it exists
if (method_exists($this, 'set' . $name) === false) {
return $this;
}
// fetch the default value from the property
$value = $value ?? $this->$name ?? null;
// store all original properties, to be able to clone them later
$this->propertyData[$name] = $value;
// handle empty values
if ($value === null) {
// replace null with a default value, if a default handler exists
if (method_exists($this, 'default' . $name) === true) {
$value = $this->{'default' . $name}();
}
// check for required properties
if ($value === null && ($required ?? $this->isRequiredProperty($name)) === true) {
throw new Exception(sprintf('The property "%s" is required', $name));
}
}
// call the setter with the final value
return $this->{'set' . $name}($value);
}
protected function setRequiredProperties(array $props, array $required)
{
foreach ($required as $propertyName) {
if (isset($props[$propertyName]) !== true) {
throw new Exception(sprintf('The property "%s" is required', $propertyName));
}
$this->{'set' . $propertyName}($props[$propertyName]);
}
}
}

244
kirby/src/Toolkit/Query.php Normal file
View file

@ -0,0 +1,244 @@
<?php
namespace Kirby\Toolkit;
use Kirby\Exception\BadMethodCallException;
use Kirby\Exception\InvalidArgumentException;
/**
* The Query class can be used to
* query arrays and objects, including their
* methods with a very simple string-based syntax.
*
* @package Kirby Toolkit
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier GmbH
* @license https://opensource.org/licenses/MIT
*/
class Query
{
const PARTS = '!\.|(\(([^()]+|(?1))*+\))(*SKIP)(*FAIL)!'; // split by dot, but not inside (nested) parens
const PARAMETERS = '!,|' . self::SKIP . '!'; // split by comma, but not inside skip groups
const NO_PNTH = '\([^(]+\)(*SKIP)(*FAIL)';
const NO_SQBR = '\[[^]]+\](*SKIP)(*FAIL)';
const NO_DLQU = '\"(?:[^"\\\\]|\\\\.)*\"(*SKIP)(*FAIL)'; // allow \" escaping inside string
const NO_SLQU = '\'(?:[^\'\\\\]|\\\\.)*\'(*SKIP)(*FAIL)'; // allow \' escaping inside string
const SKIP = self::NO_PNTH . '|' . self::NO_SQBR . '|' .
self::NO_DLQU . '|' . self::NO_SLQU;
/**
* The query string
*
* @var string
*/
protected $query;
/**
* Queryable data
*
* @var array
*/
protected $data;
/**
* Creates a new Query object
*
* @param string|null $query
* @param array|object $data
*/
public function __construct(?string $query = null, $data = [])
{
$this->query = $query;
$this->data = $data;
}
/**
* Returns the query result if anything
* can be found, otherwise returns null
*
* @return mixed
*/
public function result()
{
if (empty($this->query) === true) {
return $this->data;
}
return $this->resolve($this->query);
}
/**
* Resolves the query if anything
* can be found, otherwise returns null
*
* @param string $query
* @return mixed
*
* @throws \Kirby\Exception\BadMethodCallException If an invalid method is accessed by the query
*/
protected function resolve(string $query)
{
// direct key access in arrays
if (is_array($this->data) === true && array_key_exists($query, $this->data) === true) {
return $this->data[$query];
}
$parts = $this->parts($query);
$data = $this->data;
$value = null;
foreach ($parts as $part) {
$info = $this->part($part);
$method = $info['method'];
$args = $info['args'];
if (is_array($data)) {
if (array_key_exists($method, $data) === true) {
$value = $data[$method];
if (is_a($value, 'Closure') === true) {
$value = $value(...$args);
} elseif ($args !== []) {
throw new InvalidArgumentException('Cannot access array element ' . $method . ' with arguments');
}
} else {
static::accessError($data, $method, 'property');
}
} elseif (is_object($data)) {
if (
method_exists($data, $method) === true ||
method_exists($data, '__call') === true
) {
$value = $data->$method(...$args);
} elseif (
$args === [] && (
property_exists($data, $method) === true ||
method_exists($data, '__get') === true
)
) {
$value = $data->$method;
} else {
$label = ($args === []) ? 'method/property' : 'method';
static::accessError($data, $method, $label);
}
} else {
// further parts on a scalar/null value
static::accessError($data, $method, 'method/property');
}
// continue with the current value for the next part
$data = $value;
}
return $value;
}
/**
* Breaks the query string down into its components
*
* @param string $query
* @return array
*/
protected function parts(string $query): array
{
return preg_split(self::PARTS, trim($query), -1, PREG_SPLIT_NO_EMPTY);
}
/**
* Analyzes each part of the query string and
* extracts methods and method arguments
*
* @param string $part
* @return array
*/
protected function part(string $part): array
{
if (Str::endsWith($part, ')') === true) {
$method = Str::before($part, '(');
// the args are everything inside the *outer* parentheses
$args = Str::substr($part, Str::position($part, '(') + 1, -1);
$args = preg_split(self::PARAMETERS, $args);
$args = array_map('self::parameter', $args);
return compact('method', 'args');
} else {
return [
'method' => $part,
'args' => []
];
}
}
/**
* Converts a parameter of a query to
* its proper native PHP type
*
* @param string $arg
* @return mixed
*/
protected function parameter(string $arg)
{
$arg = trim($arg);
// string with double quotes
if (substr($arg, 0, 1) === '"' && substr($arg, -1) === '"') {
return str_replace('\"', '"', substr($arg, 1, -1));
}
// string with single quotes
if (substr($arg, 0, 1) === "'" && substr($arg, -1) === "'") {
return str_replace("\'", "'", substr($arg, 1, -1));
}
// boolean or null
switch ($arg) {
case 'null':
return null;
case 'false':
return false;
case 'true':
return true;
}
// numeric
if (is_numeric($arg) === true) {
return (float)$arg;
}
// array: split and recursive sanitizing
if (substr($arg, 0, 1) === '[' && substr($arg, -1) === ']') {
$arg = substr($arg, 1, -1);
$arg = preg_split(self::PARAMETERS, $arg);
return array_map('self::parameter', $arg);
}
// resolve parameter for objects and methods itself
return $this->resolve($arg);
}
/**
* Throws an exception for an access to an invalid method
*
* @param mixed $data Variable on which the access was tried
* @param string $name Name of the method/property that was accessed
* @param string $label Type of the name (`method`, `property` or `method/property`)
* @return void
*
* @throws \Kirby\Exception\BadMethodCallException
*/
protected static function accessError($data, string $name, string $label): void
{
$type = strtolower(gettype($data));
if ($type === 'double') {
$type = 'float';
}
$nonExisting = in_array($type, ['array', 'object']) ? 'non-existing ' : '';
$error = 'Access to ' . $nonExisting . $label . ' ' . $name . ' on ' . $type;
throw new BadMethodCallException($error);
}
}

View file

@ -0,0 +1,73 @@
<?php
namespace Kirby\Toolkit;
/**
* The Silo class is a core class to handle
* setting, getting and removing static data of
* a singleton.
*
* @package Kirby Toolkit
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier GmbH
* @license https://opensource.org/licenses/MIT
*/
class Silo
{
/**
* @var array
*/
public static $data = [];
/**
* Setter for new data.
*
* @param string|array $key
* @param mixed $value
* @return array
*/
public static function set($key, $value = null): array
{
if (is_array($key) === true) {
return static::$data = array_merge(static::$data, $key);
} else {
static::$data[$key] = $value;
return static::$data;
}
}
/**
* @param string|array $key
* @param mixed $default
* @return mixed
*/
public static function get($key = null, $default = null)
{
if ($key === null) {
return static::$data;
}
return A::get(static::$data, $key, $default);
}
/**
* Removes an item from the data array
*
* @param string|null $key
* @return array
*/
public static function remove(string $key = null): array
{
// reset the entire array
if ($key === null) {
return static::$data = [];
}
// unset a single key
unset(static::$data[$key]);
// return the array without the removed key
return static::$data;
}
}

1176
kirby/src/Toolkit/Str.php Normal file

File diff suppressed because it is too large Load diff

49
kirby/src/Toolkit/Tpl.php Normal file
View file

@ -0,0 +1,49 @@
<?php
namespace Kirby\Toolkit;
use Throwable;
/**
* Simple PHP template engine
*
* @package Kirby Toolkit
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier GmbH
* @license https://opensource.org/licenses/MIT
*/
class Tpl
{
/**
* Renders the template
*
* @param string $file
* @param array $data
* @return string
*/
public static function load(string $file = null, array $data = []): string
{
if (is_file($file) === false) {
return '';
}
ob_start();
$exception = null;
try {
F::load($file, null, $data);
} catch (Throwable $e) {
$exception = $e;
}
$content = ob_get_contents();
ob_end_clean();
if ($exception === null) {
return $content;
}
throw $exception;
}
}

527
kirby/src/Toolkit/V.php Normal file
View file

@ -0,0 +1,527 @@
<?php
namespace Kirby\Toolkit;
use Exception;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Http\Idn;
use ReflectionFunction;
use Throwable;
/**
* A set of validator methods
*
* @package Kirby Toolkit
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier GmbH
* @license https://opensource.org/licenses/MIT
*/
class V
{
/**
* An array with all installed validators
*
* @var array
*/
public static $validators = [];
/**
* Validates the given input with all passed rules
* and returns an array with all error messages.
* The array will be empty if the input is valid
*
* @param mixed $input
* @param array $rules
* @param array $messages
* @return array
*/
public static function errors($input, array $rules, $messages = []): array
{
$errors = static::value($input, $rules, $messages, false);
return $errors === true ? [] : $errors;
}
/**
* Creates a useful error message for the given validator
* and the arguments. This is used mainly internally
* to create error messages
*
* @param string $validatorName
* @param mixed ...$params
* @return string|null
*/
public static function message(string $validatorName, ...$params): ?string
{
$validatorName = strtolower($validatorName);
$translationKey = 'error.validation.' . $validatorName;
$validators = array_change_key_case(static::$validators);
$validator = $validators[$validatorName] ?? null;
if ($validator === null) {
return null;
}
$reflection = new ReflectionFunction($validator);
$arguments = [];
foreach ($reflection->getParameters() as $index => $parameter) {
$value = $params[$index] ?? null;
if (is_array($value) === true) {
try {
foreach ($value as $index => $item) {
if (is_array($item) === true) {
$value[$index] = implode('|', $item);
}
}
$value = implode(', ', $value);
} catch (Throwable $e) {
$value = '-';
}
}
$arguments[$parameter->getName()] = $value;
}
return I18n::template($translationKey, 'The "' . $validatorName . '" validation failed', $arguments);
}
/**
* Return the list of all validators
*
* @return array
*/
public static function validators(): array
{
return static::$validators;
}
/**
* Validate a single value against
* a set of rules, using all registered
* validators
*
* @param mixed $value
* @param array $rules
* @param array $messages
* @param bool $fail
* @return bool|array
*/
public static function value($value, array $rules, array $messages = [], bool $fail = true)
{
$errors = [];
foreach ($rules as $validatorName => $validatorOptions) {
if (is_int($validatorName)) {
$validatorName = $validatorOptions;
$validatorOptions = [];
}
if (is_array($validatorOptions) === false) {
$validatorOptions = [$validatorOptions];
}
$validatorName = strtolower($validatorName);
if (static::$validatorName($value, ...$validatorOptions) === false) {
$message = $messages[$validatorName] ?? static::message($validatorName, $value, ...$validatorOptions);
$errors[$validatorName] = $message;
if ($fail === true) {
throw new Exception($message);
}
}
}
return empty($errors) === true ? true : $errors;
}
/**
* Validate an input array against
* a set of rules, using all registered
* validators
*
* @param array $input
* @param array $rules
* @return bool
*/
public static function input(array $input, array $rules): bool
{
foreach ($rules as $fieldName => $fieldRules) {
$fieldValue = $input[$fieldName] ?? null;
// first check for required fields
if (
($fieldRules['required'] ?? false) === true &&
$fieldValue === null
) {
throw new Exception(sprintf('The "%s" field is missing', $fieldName));
}
// remove the required rule
unset($fieldRules['required']);
// skip validation for empty fields
if ($fieldValue === null) {
continue;
}
try {
static::value($fieldValue, $fieldRules);
} catch (Exception $e) {
throw new Exception(sprintf($e->getMessage() . ' for field "%s"', $fieldName));
}
}
return true;
}
/**
* Calls an installed validator and passes all arguments
*
* @param string $method
* @param array $arguments
* @return bool
*/
public static function __callStatic(string $method, array $arguments): bool
{
$method = strtolower($method);
$validators = array_change_key_case(static::$validators);
// check for missing validators
if (isset($validators[$method]) === false) {
throw new Exception('The validator does not exist: ' . $method);
}
return call_user_func_array($validators[$method], $arguments);
}
}
/**
* Default set of validators
*/
V::$validators = [
/**
* Valid: `'yes' | true | 1 | 'on'`
*/
'accepted' => function ($value): bool {
return V::in($value, [1, true, 'yes', 'true', '1', 'on'], true) === true;
},
/**
* Valid: `a-z | A-Z`
*/
'alpha' => function ($value, bool $unicode = false): bool {
return V::match($value, ($unicode === true ? '/^([\pL])+$/u' : '/^([a-z])+$/i')) === true;
},
/**
* Valid: `a-z | A-Z | 0-9`
*/
'alphanum' => function ($value, bool $unicode = false): bool {
return V::match($value, ($unicode === true ? '/^[\pL\pN]+$/u' : '/^([a-z0-9])+$/i')) === true;
},
/**
* Checks for numbers within the given range
*/
'between' => function ($value, $min, $max): bool {
return V::min($value, $min) === true &&
V::max($value, $max) === true;
},
/**
* Checks if the given string contains the given value
*/
'contains' => function ($value, $needle): bool {
return Str::contains($value, $needle);
},
/**
* Checks for a valid date or compares two
* dates with each other.
*
* Pass only the first argument to check for a valid date.
* Pass an operator as second argument and another date as
* third argument to compare them.
*/
'date' => function (?string $value, string $operator = null, string $test = null): bool {
$args = func_get_args();
// simple date validation
if (count($args) === 1) {
$date = date_parse($value);
return $date !== false &&
$date['error_count'] === 0 &&
$date['warning_count'] === 0;
}
$value = strtotime($value);
$test = strtotime($test);
if (is_int($value) !== true || is_int($test) !== true) {
return false;
}
switch ($operator) {
case '!=':
return $value !== $test;
case '<':
return $value < $test;
case '>':
return $value > $test;
case '<=':
return $value <= $test;
case '>=':
return $value >= $test;
case '==':
return $value === $test;
}
throw new InvalidArgumentException('Invalid date comparison operator: "' . $operator . '". Allowed operators: "==", "!=", "<", "<=", ">", ">="');
},
/**
* Valid: `'no' | false | 0 | 'off'`
*/
'denied' => function ($value): bool {
return V::in($value, [0, false, 'no', 'false', '0', 'off'], true) === true;
},
/**
* Checks for a value, which does not equal the given value
*/
'different' => function ($value, $other, $strict = false): bool {
if ($strict === true) {
return $value !== $other;
}
return $value != $other;
},
/**
* Checks for valid email addresses
*/
'email' => function ($value): bool {
if (filter_var($value, FILTER_VALIDATE_EMAIL) === false) {
try {
$email = Idn::encodeEmail($value);
} catch (Throwable $e) {
return false;
}
return filter_var($email, FILTER_VALIDATE_EMAIL) !== false;
}
return true;
},
/**
* Checks if the given string ends with the given value
*/
'endsWith' => function (string $value, string $end): bool {
return Str::endsWith($value, $end);
},
/**
* Checks for a valid filename
*/
'filename' => function ($value): bool {
return V::match($value, '/^[a-z0-9@._-]+$/i') === true &&
V::min($value, 2) === true;
},
/**
* Checks if the value exists in a list of given values
*/
'in' => function ($value, array $in, bool $strict = false): bool {
return in_array($value, $in, $strict) === true;
},
/**
* Checks for a valid integer
*/
'integer' => function ($value, bool $strict = false): bool {
if ($strict === true) {
return is_int($value) === true;
}
return filter_var($value, FILTER_VALIDATE_INT) !== false;
},
/**
* Checks for a valid IP address
*/
'ip' => function ($value): bool {
return filter_var($value, FILTER_VALIDATE_IP) !== false;
},
/**
* Checks if the value is lower than the second value
*/
'less' => function ($value, float $max): bool {
return V::size($value, $max, '<') === true;
},
/**
* Checks if the value matches the given regular expression
*/
'match' => function ($value, string $pattern): bool {
return preg_match($pattern, $value) !== 0;
},
/**
* Checks if the value does not exceed the maximum value
*/
'max' => function ($value, float $max): bool {
return V::size($value, $max, '<=') === true;
},
/**
* Checks if the value is higher than the minimum value
*/
'min' => function ($value, float $min): bool {
return V::size($value, $min, '>=') === true;
},
/**
* Checks if the number of characters in the value equals or is below the given maximum
*/
'maxLength' => function (string $value = null, $max): bool {
return Str::length(trim($value)) <= $max;
},
/**
* Checks if the number of characters in the value equals or is greater than the given minimum
*/
'minLength' => function (string $value = null, $min): bool {
return Str::length(trim($value)) >= $min;
},
/**
* Checks if the number of words in the value equals or is below the given maximum
*/
'maxWords' => function (string $value = null, $max): bool {
return V::max(explode(' ', trim($value)), $max) === true;
},
/**
* Checks if the number of words in the value equals or is below the given maximum
*/
'minWords' => function (string $value = null, $min): bool {
return V::min(explode(' ', trim($value)), $min) === true;
},
/**
* Checks if the first value is higher than the second value
*/
'more' => function ($value, float $min): bool {
return V::size($value, $min, '>') === true;
},
/**
* Checks that the given string does not contain the second value
*/
'notContains' => function ($value, $needle): bool {
return V::contains($value, $needle) === false;
},
/**
* Checks that the given value is not in the given list of values
*/
'notIn' => function ($value, $notIn): bool {
return V::in($value, $notIn) === false;
},
/**
* Checks for a valid number / numeric value (float, int, double)
*/
'num' => function ($value): bool {
return is_numeric($value) === true;
},
/**
* Checks if the value is present in the given array
*/
'required' => function ($key, array $array): bool {
return isset($array[$key]) === true &&
V::notIn($array[$key], [null, '', []]) === true;
},
/**
* Checks that the first value equals the second value
*/
'same' => function ($value, $other, bool $strict = false): bool {
if ($strict === true) {
return $value === $other;
}
return $value == $other;
},
/**
* Checks that the value has the given size
*/
'size' => function ($value, $size, $operator = '=='): bool {
// if value is field object, first convert it to a readable value
// it is important to check at the beginning as the value can be string or numeric
if (is_a($value, '\Kirby\Cms\Field') === true) {
$value = $value->value();
}
if (is_numeric($value) === true) {
$count = $value;
} elseif (is_string($value) === true) {
$count = Str::length(trim($value));
} elseif (is_array($value) === true) {
$count = count($value);
} elseif (is_object($value) === true) {
if ($value instanceof \Countable) {
$count = count($value);
} elseif (method_exists($value, 'count') === true) {
$count = $value->count();
} else {
throw new Exception('$value is an uncountable object');
}
} else {
throw new Exception('$value is of type without size');
}
switch ($operator) {
case '<':
return $count < $size;
case '>':
return $count > $size;
case '<=':
return $count <= $size;
case '>=':
return $count >= $size;
default:
return $count == $size;
}
},
/**
* Checks that the string starts with the given start value
*/
'startsWith' => function (string $value, string $start): bool {
return Str::startsWith($value, $start);
},
/**
* Checks for valid time
*/
'time' => function ($value): bool {
return V::date($value);
},
/**
* Checks for a valid Url
*/
'url' => function ($value): bool {
// In search for the perfect regular expression: https://mathiasbynens.be/demo/url-regex
// Added localhost support and removed 127.*.*.* ip restriction
$regex = '_^(?:(?:https?|ftp):\/\/)(?:\S+(?::\S*)?@)?(?:(?!10(?:\.\d{1,3}){3})(?!169\.254(?:\.\d{1,3}){2})(?!192\.168(?:\.\d{1,3}){2})(?!172\.(?:1[6-9]|2\d|3[0-1])(?:\.\d{1,3}){2})(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:localhost)|(?:(?:[a-z\x{00a1}-\x{ffff}0-9]+-?)*[a-z\x{00a1}-\x{ffff}0-9]+)(?:\.(?:[a-z\x{00a1}-\x{ffff}0-9]+-?)*[a-z\x{00a1}-\x{ffff}0-9]+)*(?:\.(?:[a-z\x{00a1}-\x{ffff}]{2,})))(?::\d{2,5})?(?:\/[^\s]*)?$_iu';
return preg_match($regex, $value) !== 0;
}
];

136
kirby/src/Toolkit/View.php Normal file
View file

@ -0,0 +1,136 @@
<?php
namespace Kirby\Toolkit;
use Exception;
use Throwable;
/**
* Simple PHP view engine
*
* @package Kirby Toolkit
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier GmbH
* @license https://opensource.org/licenses/MIT
*/
class View
{
/**
* The absolute path to the view file
*
* @var string
*/
protected $file;
/**
* The view data
*
* @var array
*/
protected $data = [];
/**
* Creates a new view object
*
* @param string $file
* @param array $data
*/
public function __construct(string $file, array $data = [])
{
$this->file = $file;
$this->data = $data;
}
/**
* Returns the view's data array
* without globals.
*
* @return array
*/
public function data(): array
{
return $this->data;
}
/**
* Checks if the template file exists
*
* @return bool
*/
public function exists(): bool
{
return is_file($this->file()) === true;
}
/**
* Returns the view file
*
* @return string|false
*/
public function file()
{
return $this->file;
}
/**
* Creates an error message for the missing view exception
*
* @return string
*/
protected function missingViewMessage(): string
{
return 'The view does not exist: ' . $this->file();
}
/**
* Renders the view
*
* @return string
*/
public function render(): string
{
if ($this->exists() === false) {
throw new Exception($this->missingViewMessage());
}
ob_start();
$exception = null;
try {
F::load($this->file(), null, $this->data());
} catch (Throwable $e) {
$exception = $e;
}
$content = ob_get_contents();
ob_end_clean();
if ($exception === null) {
return $content;
}
throw $exception;
}
/**
* Alias for View::render()
*
* @return string
*/
public function toString(): string
{
return $this->render();
}
/**
* Magic string converter to enable
* converting view objects to string
*
* @return string
*/
public function __toString(): string
{
return $this->render();
}
}

433
kirby/src/Toolkit/Xml.php Normal file
View file

@ -0,0 +1,433 @@
<?php
namespace Kirby\Toolkit;
use SimpleXMLElement;
/**
* XML parser and creator class
*
* @package Kirby Toolkit
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier GmbH
* @license https://opensource.org/licenses/MIT
*/
class Xml
{
/**
* HTML to XML conversion table for entities
*
* @var array
*/
public static $entities = [
'&nbsp;' => '&#160;', '&iexcl;' => '&#161;', '&cent;' => '&#162;', '&pound;' => '&#163;', '&curren;' => '&#164;', '&yen;' => '&#165;', '&brvbar;' => '&#166;', '&sect;' => '&#167;',
'&uml;' => '&#168;', '&copy;' => '&#169;', '&ordf;' => '&#170;', '&laquo;' => '&#171;', '&not;' => '&#172;', '&shy;' => '&#173;', '&reg;' => '&#174;', '&macr;' => '&#175;',
'&deg;' => '&#176;', '&plusmn;' => '&#177;', '&sup2;' => '&#178;', '&sup3;' => '&#179;', '&acute;' => '&#180;', '&micro;' => '&#181;', '&para;' => '&#182;', '&middot;' => '&#183;',
'&cedil;' => '&#184;', '&sup1;' => '&#185;', '&ordm;' => '&#186;', '&raquo;' => '&#187;', '&frac14;' => '&#188;', '&frac12;' => '&#189;', '&frac34;' => '&#190;', '&iquest;' => '&#191;',
'&Agrave;' => '&#192;', '&Aacute;' => '&#193;', '&Acirc;' => '&#194;', '&Atilde;' => '&#195;', '&Auml;' => '&#196;', '&Aring;' => '&#197;', '&AElig;' => '&#198;', '&Ccedil;' => '&#199;',
'&Egrave;' => '&#200;', '&Eacute;' => '&#201;', '&Ecirc;' => '&#202;', '&Euml;' => '&#203;', '&Igrave;' => '&#204;', '&Iacute;' => '&#205;', '&Icirc;' => '&#206;', '&Iuml;' => '&#207;',
'&ETH;' => '&#208;', '&Ntilde;' => '&#209;', '&Ograve;' => '&#210;', '&Oacute;' => '&#211;', '&Ocirc;' => '&#212;', '&Otilde;' => '&#213;', '&Ouml;' => '&#214;', '&times;' => '&#215;',
'&Oslash;' => '&#216;', '&Ugrave;' => '&#217;', '&Uacute;' => '&#218;', '&Ucirc;' => '&#219;', '&Uuml;' => '&#220;', '&Yacute;' => '&#221;', '&THORN;' => '&#222;', '&szlig;' => '&#223;',
'&agrave;' => '&#224;', '&aacute;' => '&#225;', '&acirc;' => '&#226;', '&atilde;' => '&#227;', '&auml;' => '&#228;', '&aring;' => '&#229;', '&aelig;' => '&#230;', '&ccedil;' => '&#231;',
'&egrave;' => '&#232;', '&eacute;' => '&#233;', '&ecirc;' => '&#234;', '&euml;' => '&#235;', '&igrave;' => '&#236;', '&iacute;' => '&#237;', '&icirc;' => '&#238;', '&iuml;' => '&#239;',
'&eth;' => '&#240;', '&ntilde;' => '&#241;', '&ograve;' => '&#242;', '&oacute;' => '&#243;', '&ocirc;' => '&#244;', '&otilde;' => '&#245;', '&ouml;' => '&#246;', '&divide;' => '&#247;',
'&oslash;' => '&#248;', '&ugrave;' => '&#249;', '&uacute;' => '&#250;', '&ucirc;' => '&#251;', '&uuml;' => '&#252;', '&yacute;' => '&#253;', '&thorn;' => '&#254;', '&yuml;' => '&#255;',
'&fnof;' => '&#402;', '&Alpha;' => '&#913;', '&Beta;' => '&#914;', '&Gamma;' => '&#915;', '&Delta;' => '&#916;', '&Epsilon;' => '&#917;', '&Zeta;' => '&#918;', '&Eta;' => '&#919;',
'&Theta;' => '&#920;', '&Iota;' => '&#921;', '&Kappa;' => '&#922;', '&Lambda;' => '&#923;', '&Mu;' => '&#924;', '&Nu;' => '&#925;', '&Xi;' => '&#926;', '&Omicron;' => '&#927;',
'&Pi;' => '&#928;', '&Rho;' => '&#929;', '&Sigma;' => '&#931;', '&Tau;' => '&#932;', '&Upsilon;' => '&#933;', '&Phi;' => '&#934;', '&Chi;' => '&#935;', '&Psi;' => '&#936;',
'&Omega;' => '&#937;', '&alpha;' => '&#945;', '&beta;' => '&#946;', '&gamma;' => '&#947;', '&delta;' => '&#948;', '&epsilon;' => '&#949;', '&zeta;' => '&#950;', '&eta;' => '&#951;',
'&theta;' => '&#952;', '&iota;' => '&#953;', '&kappa;' => '&#954;', '&lambda;' => '&#955;', '&mu;' => '&#956;', '&nu;' => '&#957;', '&xi;' => '&#958;', '&omicron;' => '&#959;',
'&pi;' => '&#960;', '&rho;' => '&#961;', '&sigmaf;' => '&#962;', '&sigma;' => '&#963;', '&tau;' => '&#964;', '&upsilon;' => '&#965;', '&phi;' => '&#966;', '&chi;' => '&#967;',
'&psi;' => '&#968;', '&omega;' => '&#969;', '&thetasym;' => '&#977;', '&upsih;' => '&#978;', '&piv;' => '&#982;', '&bull;' => '&#8226;', '&hellip;' => '&#8230;', '&prime;' => '&#8242;',
'&Prime;' => '&#8243;', '&oline;' => '&#8254;', '&frasl;' => '&#8260;', '&weierp;' => '&#8472;', '&image;' => '&#8465;', '&real;' => '&#8476;', '&trade;' => '&#8482;', '&alefsym;' => '&#8501;',
'&larr;' => '&#8592;', '&uarr;' => '&#8593;', '&rarr;' => '&#8594;', '&darr;' => '&#8595;', '&harr;' => '&#8596;', '&crarr;' => '&#8629;', '&lArr;' => '&#8656;', '&uArr;' => '&#8657;',
'&rArr;' => '&#8658;', '&dArr;' => '&#8659;', '&hArr;' => '&#8660;', '&forall;' => '&#8704;', '&part;' => '&#8706;', '&exist;' => '&#8707;', '&empty;' => '&#8709;', '&nabla;' => '&#8711;',
'&isin;' => '&#8712;', '&notin;' => '&#8713;', '&ni;' => '&#8715;', '&prod;' => '&#8719;', '&sum;' => '&#8721;', '&minus;' => '&#8722;', '&lowast;' => '&#8727;', '&radic;' => '&#8730;',
'&prop;' => '&#8733;', '&infin;' => '&#8734;', '&ang;' => '&#8736;', '&and;' => '&#8743;', '&or;' => '&#8744;', '&cap;' => '&#8745;', '&cup;' => '&#8746;', '&int;' => '&#8747;',
'&there4;' => '&#8756;', '&sim;' => '&#8764;', '&cong;' => '&#8773;', '&asymp;' => '&#8776;', '&ne;' => '&#8800;', '&equiv;' => '&#8801;', '&le;' => '&#8804;', '&ge;' => '&#8805;',
'&sub;' => '&#8834;', '&sup;' => '&#8835;', '&nsub;' => '&#8836;', '&sube;' => '&#8838;', '&supe;' => '&#8839;', '&oplus;' => '&#8853;', '&otimes;' => '&#8855;', '&perp;' => '&#8869;',
'&sdot;' => '&#8901;', '&lceil;' => '&#8968;', '&rceil;' => '&#8969;', '&lfloor;' => '&#8970;', '&rfloor;' => '&#8971;', '&lang;' => '&#9001;', '&rang;' => '&#9002;', '&loz;' => '&#9674;',
'&spades;' => '&#9824;', '&clubs;' => '&#9827;', '&hearts;' => '&#9829;', '&diams;' => '&#9830;', '&quot;' => '&#34;', '&amp;' => '&#38;', '&lt;' => '&#60;', '&gt;' => '&#62;', '&OElig;' => '&#338;',
'&oelig;' => '&#339;', '&Scaron;' => '&#352;', '&scaron;' => '&#353;', '&Yuml;' => '&#376;', '&circ;' => '&#710;', '&tilde;' => '&#732;', '&ensp;' => '&#8194;', '&emsp;' => '&#8195;',
'&thinsp;' => '&#8201;', '&zwnj;' => '&#8204;', '&zwj;' => '&#8205;', '&lrm;' => '&#8206;', '&rlm;' => '&#8207;', '&ndash;' => '&#8211;', '&mdash;' => '&#8212;', '&lsquo;' => '&#8216;',
'&rsquo;' => '&#8217;', '&sbquo;' => '&#8218;', '&ldquo;' => '&#8220;', '&rdquo;' => '&#8221;', '&bdquo;' => '&#8222;', '&dagger;' => '&#8224;', '&Dagger;' => '&#8225;', '&permil;' => '&#8240;',
'&lsaquo;' => '&#8249;', '&rsaquo;' => '&#8250;', '&euro;' => '&#8364;'
];
/**
* Closing string for void tags
*
* @var string
*/
public static $void = ' />';
/**
* Generates a single attribute or a list of attributes
*
* @param string|array $name String: A single attribute with that name will be generated.
* Key-value array: A list of attributes will be generated. Don't pass a second argument in that case.
* @param mixed $value If used with a `$name` string, pass the value of the attribute here.
* If used with a `$name` array, this can be set to `false` to disable attribute sorting.
* @return string|null The generated XML attributes string
*/
public static function attr($name, $value = null): ?string
{
if (is_array($name) === true) {
if ($value !== false) {
ksort($name);
}
$attributes = [];
foreach ($name as $key => $val) {
$a = static::attr($key, $val);
if ($a) {
$attributes[] = $a;
}
}
return implode(' ', $attributes);
}
if ($value === null || $value === '' || $value === []) {
return null;
}
if ($value === ' ') {
return strtolower($name) . '=""';
}
if (is_bool($value) === true) {
return $value === true ? strtolower($name) . '="' . strtolower($name) . '"' : null;
}
if (is_array($value) === true) {
if (isset($value['value'], $value['escape'])) {
$value = $value['escape'] === true ? static::encode($value['value']) : $value['value'];
} else {
$value = implode(' ', array_filter($value, function ($value) {
return !empty($value) || is_numeric($value);
}));
}
} else {
$value = static::encode($value);
}
return strtolower($name) . '="' . $value . '"';
}
/**
* Creates an XML string from an array
*
* Supports special array keys `@name` (element name),
* `@attributes` (XML attribute key-value array),
* `@namespaces` (array with XML namespaces) and
* `@value` (element content)
*
* @param array|string $props The source array or tag content (used internally)
* @param string $name The name of the root element
* @param bool $head Include the XML declaration head or not
* @param string $indent Indentation string, defaults to two spaces
* @param int $level The indendation level (used internally)
* @return string The XML string
*/
public static function create($props, string $name = 'root', bool $head = true, string $indent = ' ', int $level = 0): string
{
if (is_array($props) === true) {
if (A::isAssociative($props) === true) {
// a tag with attributes or named children
// extract metadata from special array keys
$name = $props['@name'] ?? $name;
$attributes = $props['@attributes'] ?? [];
$value = $props['@value'] ?? null;
if (isset($props['@namespaces'])) {
foreach ($props['@namespaces'] as $key => $namespace) {
$key = 'xmlns' . (($key)? ':' . $key : '');
$attributes[$key] = $namespace;
}
}
// continue with just the children
unset($props['@name'], $props['@attributes'], $props['@namespaces'], $props['@value']);
if (count($props) > 0) {
// there are children, use them instead of the value
$value = [];
foreach ($props as $childName => $childItem) {
// render the child, but don't include the indentation of the first line
$value[] = trim(static::create($childItem, $childName, false, $indent, $level + 1));
}
}
$result = static::tag($name, $value, $attributes, $indent, $level);
} else {
// just children
$result = [];
foreach ($props as $childItem) {
$result[] = static::create($childItem, $name, false, $indent, $level);
}
$result = implode(PHP_EOL, $result);
}
} else {
// scalar value
$result = static::tag($name, $props, null, $indent, $level);
}
if ($head === true) {
return '<?xml version="1.0" encoding="UTF-8"?>' . PHP_EOL . $result;
} else {
return $result;
}
}
/**
* Removes all HTML/XML tags and encoded chars from a string
*
* ```
* echo Xml::decode('some &uuml;ber <em>crazy</em> stuff');
* // output: some über crazy stuff
* ```
*
* @param string|null $string
* @return string
*/
public static function decode(?string $string): string
{
if ($string === null) {
$string = '';
}
$string = strip_tags($string);
return html_entity_decode($string, ENT_COMPAT, 'utf-8');
}
/**
* Converts a string to an XML-safe string
*
* Converts it to HTML-safe first and then it
* will replace HTML entities with XML entities
*
* ```php
* echo Xml::encode('some über crazy stuff');
* // output: some &#252;ber crazy stuff
* ```
*
* @param string|null $string
* @param bool $html True = Convert to HTML-safe first
* @return string
*/
public static function encode(?string $string, bool $html = true): string
{
if ($string === null) {
return '';
}
if ($html === true) {
$string = Html::encode($string, false);
}
$entities = self::entities();
$html = array_keys($entities);
$xml = array_values($entities);
return str_replace($html, $xml, $string);
}
/**
* Returns the HTML-to-XML entity translation table
*
* @return array
*/
public static function entities(): array
{
return self::$entities;
}
/**
* Parses an XML string and returns an array
*
* @param string $xml
* @return array|null Parsed array or `null` on error
*/
public static function parse(string $xml): ?array
{
$xml = @simplexml_load_string($xml, 'SimpleXMLElement', LIBXML_NOENT);
if (is_object($xml) !== true) {
return null;
}
return static::simplify($xml);
}
/**
* Breaks a SimpleXMLElement down into a simpler tree
* structure of arrays and strings
*
* @param \SimpleXMLElement $element
* @param bool $collectName Whether the element name should be collected (for the root element)
* @return array|string
*/
public static function simplify(SimpleXMLElement $element, bool $collectName = true)
{
// get all XML namespaces of the whole document to iterate over later;
// we don't need the global namespace (empty string) in the list
$usedNamespaces = $element->getNamespaces(true);
if (isset($usedNamespaces[''])) {
unset($usedNamespaces['']);
}
// now collect element metadata of the parent
$array = [];
if ($collectName === true) {
$array['@name'] = $element->getName();
}
// collect attributes with each defined document namespace;
// also check for attributes without any namespace
$attributeArray = [];
foreach (array_merge([0 => null], array_keys($usedNamespaces)) as $namespace) {
$prefix = ($namespace)? $namespace . ':' : '';
$attributes = $element->attributes($namespace, true);
foreach ($attributes as $key => $value) {
$attributeArray[$prefix . $key] = (string)$value;
}
}
if (count($attributeArray) > 0) {
$array['@attributes'] = $attributeArray;
}
// collect namespace definitions of this particular XML element
if ($namespaces = $element->getDocNamespaces(false, false)) {
$array['@namespaces'] = $namespaces;
}
// check for children with each defined document namespace;
// also check for children without any namespace
$hasChildren = false;
foreach (array_merge([0 => null], array_keys($usedNamespaces)) as $namespace) {
$prefix = ($namespace)? $namespace . ':' : '';
$children = $element->children($namespace, true);
if (count($children) > 0) {
// there are children, recursively simplify each one
$hasChildren = true;
// make a grouped collection of elements per element name
foreach ($children as $child) {
$array[$prefix . $child->getName()][] = static::simplify($child, false);
}
}
}
if ($hasChildren === true) {
// there were children of any namespace
// reduce elements where there is only one item
// of the respective type to a simple string;
// don't do anything with special `@` metadata keys
foreach ($array as $name => $item) {
if (substr($name, 0, 1) !== '@' && count($item) === 1) {
$array[$name] = $item[0];
}
}
return $array;
} else {
// we didn't find any XML children above, only use the string value
$element = (string)$element;
if (count($array) > 0) {
$array['@value'] = $element;
return $array;
} else {
return $element;
}
}
}
/**
* Builds an XML tag
*
* @param string $name Tag name
* @param array|string|null $content Scalar value or array with multiple lines of content or `null` to
* generate a self-closing tag; pass an empty string to generate empty content
* @param array $attr An associative array with additional attributes for the tag
* @param string|null $indent Indentation string, defaults to two spaces or `null` for output on one line
* @param int $level Indentation level
* @return string The generated XML
*/
public static function tag(string $name, $content = '', array $attr = null, ?string $indent = null, int $level = 0): string
{
$attr = static::attr($attr);
$start = '<' . $name . ($attr ? ' ' . $attr : '') . '>';
$startShort = '<' . $name . ($attr ? ' ' . $attr : '') . static::$void;
$end = '</' . $name . '>';
$baseIndent = $indent ? str_repeat($indent, $level) : '';
if (is_array($content) === true) {
if (is_string($indent) === true) {
$xml = $baseIndent . $start . PHP_EOL;
foreach ($content as $line) {
$xml .= $baseIndent . $indent . $line . PHP_EOL;
}
$xml .= $baseIndent . $end;
} else {
$xml = $start . implode($content) . $end;
}
} elseif ($content === null) {
$xml = $baseIndent . $startShort;
} else {
$xml = $baseIndent . $start . static::value($content) . $end;
}
return $xml;
}
/**
* Properly encodes tag contents
*
* @param mixed $value
* @return string|null
*/
public static function value($value): ?string
{
if ($value === true) {
return 'true';
}
if ($value === false) {
return 'false';
}
if (is_numeric($value) === true) {
return (string)$value;
}
if ($value === null || $value === '') {
return null;
}
if (Str::startsWith($value, '<![CDATA[') === true) {
return $value;
}
$encoded = htmlentities($value);
if ($encoded === $value) {
// no CDATA block needed
return $value;
}
// wrap everything in a CDATA block
// and ensure that it is not closed in the input string
return '<![CDATA[' . str_replace(']]>', ']]]]><![CDATA[>', $value) . ']]>';
}
}